diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml
new file mode 100644
index 0000000000..ce75b7a57c
--- /dev/null
+++ b/.github/actions/prepare-build/action.yml
@@ -0,0 +1,47 @@
+name: "Prebuilt steps for build"
+description: "Reusable steps for multiple jobs"
+inputs:
+ java_ver:
+ required: true
+ description: "Java version to install"
+ ghc_ver:
+ required: true
+ description: "GHC version to install"
+ 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.1.0
+ description: "GHC version to install"
+runs:
+ using: "composite"
+ steps:
+ - name: Setup Haskell
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ inputs.ghc_ver }}
+ cabal-version: ${{ inputs.cabal_ver }}
+
+ - name: Setup Java
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: ${{ inputs.java_ver }}
+ cache: 'gradle'
+
+ - name: Restore cached build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ${{ inputs.cache_path }}
+ dist-newstyle
+ key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml
new file mode 100644
index 0000000000..e0d32bd596
--- /dev/null
+++ b/.github/actions/prepare-release/action.yml
@@ -0,0 +1,39 @@
+name: "Upload binary and update hash"
+description: "Reusable steps for multiple jobs"
+inputs:
+ bin_path:
+ required: true
+ description: "Path to binary to upload"
+ bin_name:
+ required: true
+ description: "Name of uploaded binary"
+ bin_hash:
+ required: true
+ description: "Message with SHA to include in release"
+ github_ref:
+ required: true
+ description: "Github reference"
+ github_token:
+ required: true
+ description: "Github token"
+runs:
+ using: "composite"
+ steps:
+ - name: Upload file with specific name
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: simplex-chat/upload-release-action@v2
+ with:
+ repo_token: ${{ inputs.github_token }}
+ file: ${{ inputs.bin_path }}
+ asset_name: ${{ inputs.bin_name }}
+ tag: ${{ inputs.github_ref }}
+
+ - name: Add hash to release notes
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: simplex-chat/action-gh-release@v2
+ env:
+ GITHUB_TOKEN: ${{ inputs.github_token }}
+ with:
+ append_body: true
+ body: |
+ ${{ inputs.bin_hash }}
diff --git a/.github/actions/swap/action.yml b/.github/actions/swap/action.yml
new file mode 100644
index 0000000000..87d670b147
--- /dev/null
+++ b/.github/actions/swap/action.yml
@@ -0,0 +1,44 @@
+name: 'Set Swap Space'
+description: 'Add moar swap'
+branding:
+ icon: 'crop'
+ color: 'orange'
+inputs:
+ swap-size-gb:
+ description: 'Swap space to create, in Gigabytes.'
+ required: false
+ default: '10'
+runs:
+ using: "composite"
+ steps:
+ - name: Swap space report before modification
+ shell: bash
+ run: |
+ echo "Memory and swap:"
+ free -h
+ echo
+ swapon --show
+ echo
+ - name: Set Swap
+ shell: bash
+ run: |
+ export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
+ echo "Swap file: $SWAP_FILE"
+ if [ -z "$SWAP_FILE" ]; then
+ SWAP_FILE=/opt/swapfile
+ else
+ sudo swapoff $SWAP_FILE
+ sudo rm $SWAP_FILE
+ fi
+ sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
+ sudo chmod 600 $SWAP_FILE
+ sudo mkswap $SWAP_FILE
+ sudo swapon $SWAP_FILE
+ - name: Swap space report after modification
+ shell: bash
+ run: |
+ echo "Memory and swap:"
+ free -h
+ echo
+ swapon --show
+ echo
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2b9cc7f770..ca1bc79510 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,24 +5,75 @@ on:
branches:
- master
- stable
- - users
tags:
- "v*"
- "!*-fdroid"
- "!*-armv7a"
pull_request:
+ paths-ignore:
+ - "apps/ios"
+ - "apps/multiplatform"
+ - "blog"
+ - "docs"
+ - "fastlane"
+ - "images"
+ - "packages"
+ - "website"
+ - "README.md"
+ - "PRIVACY.md"
+
+# This workflow uses custom actions (prepare-build and prepare-release) defined in:
+#
+# .github/actions/
+# ├── prepare-build
+# │ └── action.yml
+# └── prepare-release
+# └── action.yml
+
+# Important!
+# Do not use always(), it makes build unskippable.
+# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs:
- prepare-release:
- if: startsWith(github.ref, 'refs/tags/v')
+
+# =============================
+# Global variables
+# =============================
+
+# That is the only and less hacky way to setup global variables
+# to use in strategy matrix (env:/YAML anchors doesn't work).
+# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
+# https://github.com/actions/runner/issues/1182
+# https://stackoverflow.com/a/77549656
+
+ variables:
+ runs-on: ubuntu-latest
+ outputs:
+ GHC_VER: 9.6.3
+ JAVA_VER: 17
+ steps:
+ - name: Dummy job when we have just simple variables
+ if: false
+ run: echo
+
+# =============================
+# Create release
+# =============================
+
+# Create release, but only if it's triggered by tag push.
+# On pull requests/commits push, this job will always complete.
+
+ maybe-release:
runs-on: ubuntu-latest
steps:
- name: Clone project
+ if: startsWith(github.ref, 'refs/tags/v')
uses: actions/checkout@v3
- name: Build changelog
id: build_changelog
- uses: mikepenz/release-changelog-builder-action@v4
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: simplex-chat/release-changelog-builder-action@v5
with:
configuration: .github/changelog_conf.json
failOnError: true
@@ -32,7 +83,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
- uses: softprops/action-gh-release@v1
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: simplex-chat/action-gh-release@v2
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
@@ -42,271 +94,375 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- build:
- name: build-${{ matrix.os }}-${{ matrix.ghc }}
- if: always()
- needs: prepare-release
- runs-on: ${{ matrix.os }}
+# =========================
+# Linux Build
+# =========================
+
+ build-linux:
+ name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ubuntu-${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- - os: ubuntu-20.04
+ - os: 22.04
ghc: "8.10.7"
- cache_path: ~/.cabal/store
- - os: ubuntu-20.04
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-ubuntu-20_04-x86-64
- desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- - os: ubuntu-22.04
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-ubuntu-22_04-x86-64
+ should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
+ - os: 22.04
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- - os: macos-latest
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-macos-aarch64
- desktop_asset_name: simplex-desktop-macos-aarch64.dmg
- - os: macos-13
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-macos-x86-64
- desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- - os: windows-latest
- ghc: "9.6.3"
- cache_path: C:/cabal
- asset_name: simplex-chat-windows-x86-64
- desktop_asset_name: simplex-desktop-windows-x86_64.msi
+ should_run: true
+ - os: 24.04
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
+ desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
+ should_run: true
steps:
- - name: Configure pagefile (Windows)
- if: matrix.os == 'windows-latest'
- uses: al-cheb/configure-pagefile-action@v1.3
- with:
- minimum-size: 16GB
- maximum-size: 16GB
- disk-root: "C:"
-
- - name: Clone project
+ - name: Checkout Code
+ if: matrix.should_run == true
uses: actions/checkout@v3
- - name: Setup Haskell
- uses: haskell/actions/setup@v2
+ - name: Setup swap
+ if: matrix.ghc == '8.10.7' && matrix.should_run == true
+ uses: ./.github/actions/swap
with:
- ghc-version: ${{ matrix.ghc }}
- cabal-version: "3.10.1.0"
+ swap-size-gb: 30
+
+ # Otherwise we run out of disk space with Docker build
+ - name: Free disk space
+ if: matrix.should_run == true
+ shell: bash
+ run: ./scripts/ci/linux_util_free_space.sh
- name: Restore cached build
- id: restore_cache
- uses: actions/cache/restore@v3
+ if: matrix.should_run == true
+ uses: actions/cache@v4
with:
path: |
- ${{ matrix.cache_path }}
+ ~/.cabal/store
dist-newstyle
- key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+ key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- # / Unix
+ - name: Set up Docker Buildx
+ if: matrix.should_run == true
+ uses: simplex-chat/docker-setup-buildx-action@v3
- - name: Unix prepare cabal.project.local for Mac
- if: matrix.os == 'macos-latest'
+ - name: Build and cache Docker image
+ if: matrix.should_run == true
+ uses: simplex-chat/docker-build-push-action@v6
+ with:
+ context: .
+ load: true
+ file: Dockerfile.build
+ tags: build/${{ matrix.os }}:latest
+ build-args: |
+ TAG=${{ matrix.os }}
+ GHC=${{ matrix.ghc }}
+
+ # Docker needs these flags for AppImage build:
+ # --device /dev/fuse
+ # --cap-add SYS_ADMIN
+ # --security-opt apparmor:unconfined
+ - name: Start container
+ if: matrix.should_run == true
shell: bash
run: |
- echo "ignore-project: False" >> cabal.project.local
- echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
- echo " flags: +openssl" >> cabal.project.local
+ docker run -t -d \
+ --device /dev/fuse \
+ --cap-add SYS_ADMIN \
+ --security-opt apparmor:unconfined \
+ --name builder \
+ -v ~/.cabal:/root/.cabal \
+ -v /home/runner/work/_temp:/home/runner/work/_temp \
+ -v ${{ github.workspace }}:/project \
+ build/${{ matrix.os }}:latest
- - name: Unix prepare cabal.project.local for Mac
- if: matrix.os == 'macos-13'
- shell: bash
- run: |
- echo "ignore-project: False" >> cabal.project.local
- echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
- echo " flags: +openssl" >> cabal.project.local
-
- - name: Install AppImage dependencies
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
- run: sudo apt install -y desktop-file-utils
-
- - name: Install pkg-config for Mac
- if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
- run: brew install pkg-config
-
- - name: Unix prepare cabal.project.local for Ubuntu
- if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
+ - name: Prepare cabal.project.local
+ if: matrix.should_run == true
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- - name: Unix build CLI
- id: unix_cli_build
- if: matrix.os != 'windows-latest'
+ # chmod/git commands are used to workaround permission issues when cache is restored
+ - name: Build CLI
+ if: matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
+ cabal clean
+ cabal update
+ cabal build -j --enable-tests
+ mkdir -p /out
+ for i in simplex-chat simplex-chat-test; do
+ bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
+ chmod +x "$bin"
+ mv "$bin" /out/
+ done
+ strip /out/simplex-chat
+
+ - name: Copy tests from container
+ if: matrix.should_run == true
shell: bash
run: |
- cabal build --enable-tests
- path=$(cabal list-bin simplex-chat)
- echo "bin_path=$path" >> $GITHUB_OUTPUT
- echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ docker cp builder:/out/simplex-chat-test .
- - name: Unix upload CLI binary to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
- uses: svenstaro/upload-release-action@v2
- with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.unix_cli_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
-
- - name: Unix update CLI binary hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.unix_cli_build.outputs.bin_hash }}
-
- - name: Setup Java
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
- uses: actions/setup-java@v3
- with:
- distribution: 'corretto'
- java-version: '17'
- cache: 'gradle'
-
- - name: Linux build desktop
- id: linux_desktop_build
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
+ - name: Copy CLI from container and prepare it
+ id: linux_cli_prepare
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
+ run: |
+ docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
+ path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Desktop
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
run: |
scripts/desktop/build-lib-linux.sh
cd apps/multiplatform
./gradlew packageDeb
- path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
- echo "package_path=$path" >> $GITHUB_OUTPUT
- echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Linux make AppImage
- id: linux_appimage_build
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
+ - name: Prepare Desktop
+ id: linux_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
- scripts/desktop/make-appimage-linux.sh
- path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
- echo "appimage_path=$path" >> $GITHUB_OUTPUT
- echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Mac build desktop
+ - name: Upload Desktop
+ uses: ./.github/actions/prepare-release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ with:
+ bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build AppImage
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ scripts/desktop/make-appimage-linux.sh
+
+ - name: Prepare AppImage
+ id: linux_appimage_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ shell: bash
+ run: |
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage)
+ echo "appimage_path=$path" >> $GITHUB_OUTPUT
+ echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload AppImage
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
+ bin_name: "simplex-desktop-x86_64.AppImage"
+ bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Fix permissions for cache
+ if: matrix.should_run == true
+ shell: bash
+ run: |
+ sudo chmod -R 777 dist-newstyle ~/.cabal
+ sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
+
+ - name: Run tests
+ if: matrix.should_run == true
+ timeout-minutes: 120
+ shell: bash
+ run: |
+ i=1
+ attempts=1
+ ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
+ while [ "$i" -le "$attempts" ]; do
+ if ./simplex-chat-test; then
+ break
+ else
+ echo "Attempt $i failed, retrying..."
+ i=$((i + 1))
+ sleep 1
+ fi
+ done
+ if [ "$i" -gt "$attempts" ]; then
+ echo "All "$attempts" attempts failed."
+ exit 1
+ fi
+
+# =========================
+# MacOS Build
+# =========================
+
+ build-macos:
+ name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-latest
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-macos-aarch64
+ desktop_asset_name: simplex-desktop-macos-aarch64.dmg
+ openssl_dir: "/opt/homebrew/opt"
+ - os: macos-13
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-macos-x86-64
+ desktop_asset_name: simplex-desktop-macos-x86_64.dmg
+ openssl_dir: "/usr/local/opt"
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Prepare build
+ uses: ./.github/actions/prepare-build
+ with:
+ java_ver: ${{ needs.variables.outputs.JAVA_VER }}
+ ghc_ver: ${{ matrix.ghc }}
+ os: ${{ matrix.os }}
+ github_ref: ${{ github.ref }}
+
+ - name: Install OpenSSL
+ run: brew install openssl@3.0
+
+ - name: Prepare cabal.project.local
+ shell: bash
+ run: |
+ echo "ignore-project: False" >> cabal.project.local
+ echo "package simplexmq" >> cabal.project.local
+ echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
+ echo "" >> cabal.project.local
+ echo "package direct-sqlcipher" >> cabal.project.local
+ echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
+ echo " flags: +openssl" >> cabal.project.local
+
+ - name: Build CLI
+ id: mac_cli_build
+ shell: bash
+ run: |
+ cabal build -j --enable-tests
+ path=$(cabal list-bin simplex-chat)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Desktop
id: mac_desktop_build
- if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
+ if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
run: |
- scripts/build-desktop-mac.sh
+ scripts/ci/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT
- echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Linux upload desktop package to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
- uses: svenstaro/upload-release-action@v2
+ - name: Upload Desktop
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.linux_desktop_build.outputs.package_path }}
- asset_name: ${{ matrix.desktop_asset_name }}
- tag: ${{ github.ref }}
+ bin_path: ${{ steps.mac_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- - name: Linux update desktop package hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.linux_desktop_build.outputs.package_hash }}
-
- - name: Linux upload AppImage to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
- uses: svenstaro/upload-release-action@v2
- with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
- asset_name: simplex-desktop-x86_64.AppImage
- tag: ${{ github.ref }}
-
- - name: Linux update AppImage hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.linux_appimage_build.outputs.appimage_hash }}
-
- - name: Mac upload desktop package to release
- if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
- uses: svenstaro/upload-release-action@v2
- with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.mac_desktop_build.outputs.package_path }}
- asset_name: ${{ matrix.desktop_asset_name }}
- tag: ${{ github.ref }}
-
- - name: Mac update desktop package hash
- if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.mac_desktop_build.outputs.package_hash }}
-
- - name: Cache unix build
- uses: actions/cache/save@v3
- if: matrix.os != 'windows-latest'
- with:
- path: |
- ${{ matrix.cache_path }}
- dist-newstyle
- key: ${{ steps.restore_cache.outputs.cache-primary-key }}
-
- - name: Unix test
- if: matrix.os != 'windows-latest'
- timeout-minutes: 40
+ - name: Run tests
+ timeout-minutes: 120
shell: bash
- run: cabal test --test-show-details=direct
+ run: |
+ i=1
+ attempts=1
+ ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
+ while [ "$i" -le "$attempts" ]; do
+ if cabal test --test-show-details=direct; then
+ break
+ else
+ echo "Attempt $i failed, retrying..."
+ i=$((i + 1))
+ sleep 1
+ fi
+ done
+ if [ "$i" -gt "$attempts" ]; then
+ echo "All "$attempts" attempts failed."
+ exit 1
+ fi
- # Unix /
+# =========================
+# Windows Build
+# =========================
- # / Windows
+ build-windows:
+ name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-windows-x86-64
+ desktop_asset_name: simplex-desktop-windows-x86_64.msi
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v3
- # * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
- # * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
+ - name: Prepare build
+ uses: ./.github/actions/prepare-build
+ with:
+ java_ver: ${{ needs.variables.outputs.JAVA_VER }}
+ ghc_ver: ${{ matrix.ghc }}
+ os: ${{ matrix.os }}
+ cache_path: "C:/cabal"
+ github_ref: ${{ github.ref }}
+ - name: Configure pagefile (Windows)
+ uses: simplex-chat/configure-pagefile-action@v1.4
+ with:
+ minimum-size: 16GB
+ maximum-size: 16GB
+ disk-root: "C:"
+
- name: 'Setup MSYS2'
- if: matrix.os == 'windows-latest'
- uses: msys2/setup-msys2@v2
+ uses: simplex-chat/setup-msys2@v2
with:
msystem: ucrt64
update: true
@@ -318,15 +474,14 @@ jobs:
toolchain:p
cmake:p
-
- - name: Windows build
- id: windows_build
- if: matrix.os == 'windows-latest'
+ # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
+ - name: Build CLI
+ id: windows_cli_build
shell: msys2 {0}
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/prepare-openssl-windows.sh
- openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
+ openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
@@ -336,70 +491,42 @@ jobs:
rm -rf dist-newstyle/src/direct-sq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal
- cabal build --enable-tests
+ cabal build -j --enable-tests
rm -rf dist-newstyle/src/direct-sq*
path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT
- echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload CLI binary to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: svenstaro/upload-release-action@v2
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.windows_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
+ bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- - name: Windows update CLI binary hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.windows_build.outputs.bin_hash }}
-
- - name: Windows build desktop
+ - name: Build Desktop
id: windows_desktop_build
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
+ if: startsWith(github.ref, 'refs/tags/v')
shell: msys2 {0}
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew packageMsi
+ rm -rf dist-newstyle/src/direct-sq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
- echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload desktop package to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: svenstaro/upload-release-action@v2
+ - name: Upload Desktop
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.windows_desktop_build.outputs.package_path }}
- asset_name: ${{ matrix.desktop_asset_name }}
- tag: ${{ github.ref }}
-
- - name: Windows update desktop package hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.windows_desktop_build.outputs.package_hash }}
-
- - name: Cache windows build
- uses: actions/cache/save@v3
- if: matrix.os == 'windows-latest'
- with:
- path: |
- ${{ matrix.cache_path }}
- dist-newstyle
- key: ${{ steps.restore_cache.outputs.cache-primary-key }}
-
- # Windows /
+ bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml
new file mode 100644
index 0000000000..7de44addc7
--- /dev/null
+++ b/.github/workflows/reproduce-schedule.yml
@@ -0,0 +1,45 @@
+name: Reproduce latest release
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 2 * * *' # every day at 02:00 night
+
+jobs:
+ reproduce:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Get latest release
+ shell: bash
+ run: |
+ curl --proto '=https' \
+ --tlsv1.2 \
+ -sSf -L \
+ 'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
+ 2>/dev/null | \
+ grep -i "tag_name" | \
+ awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
+
+ - name: Execute reproduce script
+ run: |
+ ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
+
+ - name: Check if build has been reproduced
+ env:
+ url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
+ user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
+ pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
+ run: |
+ if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
+ exit 0
+ else
+ curl --proto '=https' --tlsv1.2 -sSf \
+ -u "${user}:${pass}" \
+ -H 'Content-Type: application/json' \
+ -d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
+ "$url"
+ exit 1
+ fi
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 7fc66308f8..5fbe8293bc 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -10,6 +10,7 @@ on:
- blog/**
- docs/**
- .github/workflows/web.yml
+ - PRIVACY.md
jobs:
build:
@@ -32,7 +33,7 @@ jobs:
./website/web.sh
- name: Deploy
- uses: peaceiris/actions-gh-pages@v3
+ uses: simplex-chat/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index e3ea5d267b..645b55ec9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,7 @@ website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
+result
# Ignore API documentation
website/api-docs/
diff --git a/Dockerfile b/Dockerfile
index 6c60195f97..cdcbc40d7d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
-RUN cabal build exe:simplex-chat
+RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
diff --git a/Dockerfile.build b/Dockerfile.build
new file mode 100644
index 0000000000..76bb1127f2
--- /dev/null
+++ b/Dockerfile.build
@@ -0,0 +1,92 @@
+# syntax=docker/dockerfile:1.7.0-labs
+ARG TAG=24.04
+FROM ubuntu:${TAG} AS build
+
+### Build stage
+
+ARG GHC=9.6.3
+ARG CABAL=3.10.1.0
+ARG JAVA=17
+
+ENV TZ=Etc/UTC \
+ DEBIAN_FRONTEND=noninteractive
+
+# Install curl, git and and simplex-chat dependencies
+RUN apt-get update && \
+ apt-get install -y curl \
+ libpq-dev \
+ git \
+ sqlite3 \
+ libsqlite3-dev \
+ build-essential \
+ libgmp3-dev \
+ zlib1g-dev \
+ llvm \
+ cmake \
+ llvm-dev \
+ libnuma-dev \
+ libssl-dev \
+ desktop-file-utils \
+ patchelf \
+ ca-certificates \
+ zip \
+ wget \
+ fuse3 \
+ file \
+ appstream \
+ gpg \
+ unzip &&\
+ ln -s /bin/fusermount /bin/fusermount3 || :
+
+# Install Java Coretto
+# Required, because official Java in Ubuntu
+# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
+# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
+# to fix this :(
+RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
+ echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
+ apt update &&\
+ apt install -y java-${JAVA}-amazon-corretto-jdk
+
+# Specify bootstrap Haskell versions
+ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
+ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
+
+# Do not install Stack
+ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
+ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
+
+# Install ghcup
+RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
+
+# Adjust PATH
+ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+
+# Set both as default
+RUN ghcup set ghc "${GHC}" && \
+ ghcup set cabal "${CABAL}"
+
+#=====================
+# Install Android SDK
+#=====================
+ARG SDK_VERSION=13114758
+
+ENV SDK_VERSION=$SDK_VERSION \
+ ANDROID_HOME=/root
+
+RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
+ unzip tools.zip && rm tools.zip && \
+ mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
+ ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
+
+ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
+
+# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
+RUN mkdir -p ~/.android ~/.gradle && \
+ touch ~/.android/repositories.cfg && \
+ echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
+ yes | sdkmanager --licenses >/dev/null
+
+ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
+
+WORKDIR /project
diff --git a/PRIVACY.md b/PRIVACY.md
index 66dff0e807..18e5539726 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -3,63 +3,93 @@ layout: layouts/privacy.html
permalink: /privacy/index.html
---
-# SimpleX Chat Privacy Policy and Conditions of Use
+# SimpleX Chat Operators Privacy Policy and Conditions of Use
-SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
+## Summary
-SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
+[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
+
+[Privacy policy](#privacy-policy) covers:
+- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
+- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
+- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
+- [how users connect](#connections-with-other-users) without any user profile identifiers.
+- [iOS push notifications](#ios-push-notifications) privacy limitations.
+- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
+- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
+- [source code license](#source-code-license) and [updates to this document](#updates).
+
+[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
+
+*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
+
+## Introduction
+
+SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
+
+SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
-If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
-SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
+### General principles
-SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
+SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
-SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
+SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
-While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
+SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
-We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
+SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
-SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
+
+The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+
+The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits – see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
### Your information
#### User profiles
-Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
+Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
-When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
+When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
#### Messages and Files
-SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
+SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages – you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
-You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
+#### Private message delivery
-The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
+You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
+
+In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
+
+You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
+
+*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
+
+#### Storage of messages and files on the servers
+
+The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
-If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
-
-As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
-
-In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
+The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
#### Connections with other users
-When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
+When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default.
-SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
+Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy
@@ -75,6 +105,8 @@ You can always safely replace the initial part of the link `https://simplex.chat
#### iOS Push Notifications
+This section applies only to the notification servers operated by SimpleX Chat Ltd.
+
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
@@ -83,93 +115,132 @@ You can read more about the design of iOS push notifications [here](./blog/20220
#### Another information stored on the servers
-Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
+Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
#### SimpleX Directory
+This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
+
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
+#### Public groups and content channels
+
+You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license:
+- to all recipients:
+ - to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted.
+ - to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message.
+- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages.
+
+Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators.
+
#### User Support
-If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
+The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
-### Information we may share
+### Preset Server Operators
-SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
+Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
-We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+Preset server operators must not provide general access to their servers or the data on their servers to each other.
-The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
+Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users.
+
+### Information Preset Server Operators May Share
+
+The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
+
+SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+
+The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
-- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
+- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
-At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
+By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
-We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+
+### Source code license
+
+As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
+
+In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates
-We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
+This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
-Please also read our Conditions of Use of Software and Infrastructure below.
+This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy.
-If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+Please also read The Conditions of Use of Software and Infrastructure below.
+
+If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure
-You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
+You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not.
-**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
+**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country.
-**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
+**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
-**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
+**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
-**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
+**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
-**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
+**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
-**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
+**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages.
-**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
+**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way.
-**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
+**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes.
-**Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
+**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
-**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
+**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
-**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts.
+**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App:
+- is compatible with the protocol specifications not older than 1 year,
+- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots),
+- implements the same limits, rules and restrictions as Software,
+- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure,
+- displays the notice that it is the App for using SimpleX network,
+- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license,
+- does NOT use the branding of SimpleX Chat Ltd without the permission,
+- does NOT pretend to be Software,
+- complies with these Conditions of use.
+
+**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
-**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
+**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
-**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
+**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
-**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
+**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
-**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
+**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
-**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
+**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
-**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
+**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
-**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
-**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
+**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time.
-**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
+**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions.
-**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
+**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications.
-**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
+**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat.
-**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
+**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
-Updated April 24, 2024
+Updated March 3, 2025
diff --git a/README.md b/README.md
index 1a500187c0..554c6068d9 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
-[
](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [
](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [
](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
+[
](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [
](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [
](https://www.whonix.org/wiki/Chat#Recommendation) [
](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat!
@@ -72,7 +72,7 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
-You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
-[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
+[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@@ -110,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
+## Contribute
+
+We would love to have you join the development! You can help us with:
+
+- [share the color theme](./docs/THEMES.md) you use in Android app!
+- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
+- contributing to SimpleX Chat knowledge-base.
+- developing features - please connect to us via chat so we can help you get started.
+
## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@@ -141,15 +150,6 @@ Join our translators to help SimpleX grow!
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
-## Contribute
-
-We would love to have you join the development! You can help us with:
-
-- [share the color theme](./docs/THEMES.md) you use in Android app!
-- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
-- contributing to SimpleX Chat knowledge-base.
-- developing features - please connect to us via chat so we can help you get started.
-
## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -163,13 +163,15 @@ Your donations help us raise more funds - any amount, even the price of the cup
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
-- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
-- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
+- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
+- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
-- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
-- USDT:
- - Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
-- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
+- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
+- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
+- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
+- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
+- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
+- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins.
Thank you,
@@ -233,34 +235,28 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
+[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
+
+[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
+
+[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
+
+[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
+
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
-[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
-
-[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
-
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
-[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
-
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
-[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
-
-[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
-
-[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
-
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
-[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
-
[All updates](./blog)
## :zap: Quick installation of a terminal app
@@ -300,25 +296,28 @@ What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
-4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
-5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
-6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
-7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
-8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
-9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
-10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
-11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
-12. Manual messaging queue rotations to move conversation to another SMP relay.
-13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
-14. Local files encryption.
+4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
+5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
+6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
+7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
+8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
+9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
+10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
+11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
+12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
+13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
+14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
+15. Manual messaging queue rotations to move conversation to another SMP relay.
+16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
+17. Local files encryption.
+18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
We plan to add:
-1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
-2. Post-quantum resistant key exchange in double ratchet protocol.
-3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
-4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
-5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
+1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
+2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
+3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially.
+4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers
@@ -386,9 +385,11 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
+- ✅ Support multiple network operators in the app.
+- 🏗 Large groups, communities and public channels.
+- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users.
-- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
@@ -407,7 +408,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
-The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+
+The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift
index dd91d4af68..3f6998c9ec 100644
--- a/apps/ios/Shared/AppDelegate.swift
+++ b/apps/ios/Shared/AppDelegate.swift
@@ -15,16 +15,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
- NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
prepareForLaunch()
+ deleteOldChatArchive()
return true
}
- @objc func pasteboardChanged() {
- ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
- }
-
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
@@ -58,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active
} catch {
- if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr {
+ if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr {
m.tokenStatus = .expired
}
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json
new file mode 100644
index 0000000000..8e38b499dd
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "symbols": [
+ {
+ "filename": "checkmark.2.svg",
+ "idiom": "universal"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg
new file mode 100644
index 0000000000..577fa1db76
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg
@@ -0,0 +1,227 @@
+
+
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json
new file mode 100644
index 0000000000..11a91cb811
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "symbols": [
+ {
+ "filename": "checkmark.wide.svg",
+ "idiom": "universal"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg
new file mode 100644
index 0000000000..b5dfc6b3de
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg
@@ -0,0 +1,218 @@
+
+
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json
new file mode 100644
index 0000000000..d3a15f9a33
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_logo_blue_white.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png
new file mode 100644
index 0000000000..e1d6dda4fe
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json
new file mode 100644
index 0000000000..ad18e60448
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_logo_blue.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png
new file mode 100644
index 0000000000..87f1373d75
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json
new file mode 100644
index 0000000000..16686bdf80
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_symbol_blue-white.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png
new file mode 100644
index 0000000000..0793b0ee85
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
new file mode 100644
index 0000000000..cb29f09fe1
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "vertical_logo_x1.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "vertical_logo_x2.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "vertical_logo_x3.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png
new file mode 100644
index 0000000000..f916e43ea9
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png
new file mode 100644
index 0000000000..bb35878f0c
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png
new file mode 100644
index 0000000000..c55f481b36
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png differ
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 1f41e7c31d..2ad8d546f2 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -9,10 +9,21 @@ import SwiftUI
import Intents
import SimpleXChat
+private enum NoticesSheet: Identifiable {
+ case whatsNew(updatedConditions: Bool)
+
+ var id: String {
+ switch self {
+ case .whatsNew: return "whatsNew"
+ }
+ }
+}
+
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
+ @ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
@@ -29,12 +40,13 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
- @State private var showSettings = false
- @State private var showWhatsNew = false
+ @State private var noticesShown = false
+ @State private var noticesSheetItem: NoticesSheet? = nil
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil
+ @State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40
@@ -62,7 +74,7 @@ struct ContentView: View {
}
}
- @ViewBuilder func allViews() -> some View {
+ func allViews() -> some View {
ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@@ -86,7 +98,7 @@ struct ContentView: View {
callView(call)
}
- if !showSettings, let la = chatModel.laRequest {
+ if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
LocalAuthView(authRequest: la)
.onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
@@ -109,9 +121,6 @@ struct ContentView: View {
}
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
- .sheet(isPresented: $showSettings) {
- SettingsView(showSettings: $showSettings)
- }
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true }
@@ -200,7 +209,7 @@ struct ContentView: View {
}
}
- @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
+ private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer()
@@ -253,7 +262,8 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
- ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
+ ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
+ .redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
@@ -262,17 +272,31 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- if !showWhatsNew {
- showWhatsNew = shouldShowWhatsNew()
+ if !noticesShown {
+ let showWhatsNew = shouldShowWhatsNew()
+ let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
+ noticesShown = showWhatsNew || showUpdatedConditions
+ if showWhatsNew || showUpdatedConditions {
+ noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
+ }
}
}
}
prefShowLANotice = true
connectViaUrl()
+ showReRegisterTokenAlert()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
- .sheet(isPresented: $showWhatsNew) {
- WhatsNewView()
+ .onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
+ .sheet(item: $noticesSheetItem) { item in
+ switch item {
+ case let .whatsNew(updatedConditions):
+ WhatsNewView(updatedConditions: updatedConditions)
+ .modifier(ThemedBackground())
+ .if(updatedConditions) { v in
+ v.task { await setConditionsNotified_() }
+ }
+ }
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()
@@ -282,6 +306,21 @@ struct ContentView: View {
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
+ if let url = userActivity.webpageURL {
+ logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)")
+ chatModel.appOpenUrl = url
+ }
+ }
+ }
+
+ private func setConditionsNotified_() async {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ try await setConditionsNotified(conditionsId: conditionsId)
+ } catch let error {
+ logger.error("setConditionsNotified error: \(responseError(error))")
+ }
}
private func processUserActivity(_ activity: NSUserActivity) {
@@ -300,9 +339,18 @@ struct ContentView: View {
if let contactId = contacts?.first?.personHandle?.value,
let chat = chatModel.getChat(contactId),
case let .direct(contact) = chat.chatInfo {
- logger.debug("callToRecentContact: schedule call")
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- CallController.shared.startCall(contact, mediaType)
+ let activeCall = chatModel.activeCall
+ // This line works when a user clicks on a video button in CallKit UI while in call.
+ // The app tries to make another call to the same contact and overwite activeCall instance making its state broken
+ if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
+ Task {
+ await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
+ }
+ } else if activeCall == nil {
+ logger.debug("callToRecentContact: schedule call")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ CallController.shared.startCall(contact, mediaType)
+ }
}
}
}
@@ -395,12 +443,12 @@ struct ContentView: View {
}
func connectViaUrl() {
- dismissAllSheets() {
- let m = ChatModel.shared
- if let url = m.appOpenUrl {
- m.appOpenUrl = nil
+ let m = ChatModel.shared
+ if let url = m.appOpenUrl {
+ m.appOpenUrl = nil
+ dismissAllSheets() {
var path = url.path
- if (path == "/contact" || path == "/invitation") {
+ if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
planAndConnect(
@@ -417,6 +465,21 @@ struct ContentView: View {
}
}
+ func showReRegisterTokenAlert() {
+ dismissAllSheets() {
+ let m = ChatModel.shared
+ if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken {
+ chatModel.reRegisterTknStatus = nil
+ AlertManager.shared.showAlert(Alert(
+ title: Text("Notifications error"),
+ message: Text(tokenStatusInfo(errorTknStatus, register: true)),
+ primaryButton: .default(Text("Register")) { reRegisterToken(token: token) },
+ secondaryButton: .cancel()
+ ))
+ }
+ }
+ }
+
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
}
diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift
new file mode 100644
index 0000000000..3bf4cb7b56
--- /dev/null
+++ b/apps/ios/Shared/Model/AppAPITypes.swift
@@ -0,0 +1,2281 @@
+//
+// APITypes.swift
+// SimpleX
+//
+// Created by EP on 01/05/2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SimpleXChat
+import SwiftUI
+
+// some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised
+enum ChatCommand: ChatCmdProtocol {
+ case showActiveUser
+ case createActiveUser(profile: Profile?, pastTimestamp: Bool)
+ case listUsers
+ case apiSetActiveUser(userId: Int64, viewPwd: String?)
+ case setAllContactReceipts(enable: Bool)
+ case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
+ case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
+ case apiHideUser(userId: Int64, viewPwd: String)
+ case apiUnhideUser(userId: Int64, viewPwd: String)
+ case apiMuteUser(userId: Int64)
+ case apiUnmuteUser(userId: Int64)
+ case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?)
+ case startChat(mainApp: Bool, enableSndFiles: Bool)
+ case checkChatRunning
+ case apiStopChat
+ case apiActivateChat(restoreChat: Bool)
+ case apiSuspendChat(timeoutMicroseconds: Int)
+ case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String)
+ case apiSetEncryptLocalFiles(enable: Bool)
+ case apiExportArchive(config: ArchiveConfig)
+ case apiImportArchive(config: ArchiveConfig)
+ case apiDeleteStorage
+ case apiStorageEncryption(config: DBEncryptionConfig)
+ case testStorageEncryption(key: String)
+ case apiSaveSettings(settings: AppSettings)
+ case apiGetSettings(settings: AppSettings)
+ case apiGetChatTags(userId: Int64)
+ case apiGetChats(userId: Int64)
+ case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String)
+ case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
+ case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
+ case apiCreateChatTag(tag: ChatTagData)
+ case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
+ case apiDeleteChatTag(tagId: Int64)
+ case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData)
+ case apiReorderChatTags(tagIds: [Int64])
+ case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
+ case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
+ case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
+ case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
+ case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
+ case apiArchiveReceivedReports(groupId: Int64)
+ case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode)
+ case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
+ case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
+ case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
+ case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
+ case apiGetNtfToken
+ case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
+ case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
+ case apiCheckToken(token: DeviceToken)
+ case apiDeleteToken(token: DeviceToken)
+ case apiGetNtfConns(nonce: String, encNtfInfo: String)
+ case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq])
+ case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
+ case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
+ case apiJoinGroup(groupId: Int64)
+ case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole)
+ case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool)
+ case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool)
+ case apiLeaveGroup(groupId: Int64)
+ case apiListMembers(groupId: Int64)
+ case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
+ case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool)
+ case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
+ case apiDeleteGroupLink(groupId: Int64)
+ case apiGetGroupLink(groupId: Int64)
+ case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
+ case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
+ case apiTestProtoServer(userId: Int64, server: String)
+ case apiGetServerOperators
+ case apiSetServerOperators(operators: [ServerOperator])
+ case apiGetUserServers(userId: Int64)
+ case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers])
+ case apiValidateServers(userId: Int64, userServers: [UserOperatorServers])
+ case apiGetUsageConditions
+ case apiSetConditionsNotified(conditionsId: Int64)
+ case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
+ case apiSetChatItemTTL(userId: Int64, seconds: Int64)
+ case apiGetChatItemTTL(userId: Int64)
+ case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?)
+ case apiSetNetworkConfig(networkConfig: NetCfg)
+ case apiGetNetworkConfig
+ case apiSetNetworkInfo(networkInfo: UserNetworkInfo)
+ case reconnectAllServers
+ case reconnectServer(userId: Int64, smpServer: String)
+ case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
+ case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
+ case apiContactInfo(contactId: Int64)
+ case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
+ case apiContactQueueInfo(contactId: Int64)
+ case apiGroupMemberQueueInfo(groupId: Int64, groupMemberId: Int64)
+ case apiSwitchContact(contactId: Int64)
+ case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64)
+ case apiAbortSwitchContact(contactId: Int64)
+ case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64)
+ case apiSyncContactRatchet(contactId: Int64, force: Bool)
+ case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool)
+ case apiGetContactCode(contactId: Int64)
+ case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64)
+ case apiVerifyContact(contactId: Int64, connectionCode: String?)
+ case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
+ case apiAddContact(userId: Int64, short: Bool, incognito: Bool)
+ case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
+ case apiChangeConnectionUser(connId: Int64, userId: Int64)
+ case apiConnectPlan(userId: Int64, connLink: String)
+ case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink)
+ case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
+ case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode)
+ case apiClearChat(type: ChatType, id: Int64)
+ case apiListContacts(userId: Int64)
+ case apiUpdateProfile(userId: Int64, profile: Profile)
+ case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
+ case apiSetContactAlias(contactId: Int64, localAlias: String)
+ case apiSetGroupAlias(groupId: Int64, localAlias: String)
+ case apiSetConnectionAlias(connId: Int64, localAlias: String)
+ case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?)
+ case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?)
+ case apiCreateMyAddress(userId: Int64, short: Bool)
+ case apiDeleteMyAddress(userId: Int64)
+ case apiShowMyAddress(userId: Int64)
+ case apiSetProfileAddress(userId: Int64, on: Bool)
+ case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?)
+ case apiAcceptContact(incognito: Bool, contactReqId: Int64)
+ case apiRejectContact(contactReqId: Int64)
+ // WebRTC calls
+ case apiSendCallInvitation(contact: Contact, callType: CallType)
+ case apiRejectCall(contact: Contact)
+ case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer)
+ case apiSendCallAnswer(contact: Contact, answer: WebRTCSession)
+ case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo)
+ case apiEndCall(contact: Contact)
+ case apiGetCallInvitations
+ case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
+ // WebRTC calls /
+ case apiGetNetworkStatuses
+ case apiChatRead(type: ChatType, id: Int64)
+ case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
+ case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
+ case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
+ case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
+ case cancelFile(fileId: Int64)
+ // remote desktop commands
+ case setLocalDeviceName(displayName: String)
+ case connectRemoteCtrl(xrcpInvitation: String)
+ case findKnownRemoteCtrl
+ case confirmRemoteCtrl(remoteCtrlId: Int64)
+ case verifyRemoteCtrlSession(sessionCode: String)
+ case listRemoteCtrls
+ case stopRemoteCtrl
+ case deleteRemoteCtrl(remoteCtrlId: Int64)
+ case apiUploadStandaloneFile(userId: Int64, file: CryptoFile)
+ case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile)
+ case apiStandaloneFileInfo(url: String)
+ // misc
+ case showVersion
+ case getAgentSubsTotal(userId: Int64)
+ case getAgentServersSummary(userId: Int64)
+ case resetAgentServersStats
+ case string(String)
+
+ var cmdString: String {
+ get {
+ switch self {
+ case .showActiveUser: return "/u"
+ case let .createActiveUser(profile, pastTimestamp):
+ let user = NewUser(profile: profile, pastTimestamp: pastTimestamp)
+ return "/_create user \(encodeJSON(user))"
+ case .listUsers: return "/users"
+ case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))"
+ case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))"
+ case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings):
+ let umrs = userMsgReceiptSettings
+ return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
+ case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings):
+ let umrs = userMsgReceiptSettings
+ return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
+ case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
+ case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
+ case let .apiMuteUser(userId): return "/_mute user \(userId)"
+ case let .apiUnmuteUser(userId): return "/_unmute user \(userId)"
+ case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))"
+ case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))"
+ case .checkChatRunning: return "/_check running"
+ case .apiStopChat: return "/_stop"
+ case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))"
+ case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
+ case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))"
+ case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
+ case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
+ case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
+ case .apiDeleteStorage: return "/_db delete"
+ case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))"
+ case let .testStorageEncryption(key): return "/db test key \(key)"
+ case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))"
+ case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
+ case let .apiGetChatTags(userId): return "/_get tags \(userId)"
+ case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
+ case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" +
+ (search == "" ? "" : " search=\(search)")
+ case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
+ case let .apiSendMessages(type, id, live, ttl, composedMessages):
+ let msgs = encodeJSON(composedMessages)
+ let ttlStr = ttl != nil ? "\(ttl!)" : "default"
+ return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
+ case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
+ case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
+ case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))"
+ case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiCreateChatItems(noteFolderId, composedMessages):
+ let msgs = encodeJSON(composedMessages)
+ return "/_create *\(noteFolderId) json \(msgs)"
+ case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
+ return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
+ case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
+ case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
+ case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)"
+ case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
+ case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
+ case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))"
+ case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
+ let ttlStr = ttl != nil ? "\(ttl!)" : "default"
+ return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
+ case .apiGetNtfToken: return "/_ntf get "
+ case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
+ case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
+ case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)"
+ case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
+ case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
+ case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))"
+ case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
+ case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
+ case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
+ case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)"
+ case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))"
+ case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))"
+ case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
+ case let .apiListMembers(groupId): return "/_members #\(groupId)"
+ case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
+ case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))"
+ case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)"
+ case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
+ case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
+ case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
+ case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
+ case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
+ case .apiGetServerOperators: return "/_operators"
+ case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
+ case let .apiGetUserServers(userId): return "/_servers \(userId)"
+ case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))"
+ case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))"
+ case .apiGetUsageConditions: return "/_conditions"
+ case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)"
+ case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
+ case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
+ case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
+ case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))"
+ case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
+ case .apiGetNetworkConfig: return "/network"
+ case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
+ case .reconnectAllServers: return "/reconnect"
+ case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)"
+ case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
+ case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
+ case let .apiContactInfo(contactId): return "/_info @\(contactId)"
+ case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
+ case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)"
+ case let .apiGroupMemberQueueInfo(groupId, groupMemberId): return "/_queue info #\(groupId) \(groupMemberId)"
+ case let .apiSwitchContact(contactId): return "/_switch @\(contactId)"
+ case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)"
+ case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)"
+ case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)"
+ case let .apiSyncContactRatchet(contactId, force): if force {
+ return "/_sync @\(contactId) force=on"
+ } else {
+ return "/_sync @\(contactId)"
+ }
+ case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force {
+ return "/_sync #\(groupId) \(groupMemberId) force=on"
+ } else {
+ return "/_sync #\(groupId) \(groupMemberId)"
+ }
+ case let .apiGetContactCode(contactId): return "/_get code @\(contactId)"
+ case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)"
+ case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)"
+ case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)"
+ case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)"
+ case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
+ case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))"
+ case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
+ case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
+ case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)"
+ case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")"
+ case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
+ case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)"
+ case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
+ case let .apiListContacts(userId): return "/_contacts \(userId)"
+ case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
+ case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
+ case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
+ case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))"
+ case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
+ case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")"
+ case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")"
+ case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))"
+ case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)"
+ case let .apiShowMyAddress(userId): return "/_show_address \(userId)"
+ case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
+ case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))"
+ case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)"
+ case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
+ case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
+ case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)"
+ case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))"
+ case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))"
+ case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))"
+ case let .apiEndCall(contact): return "/_call end @\(contact.apiId)"
+ case .apiGetCallInvitations: return "/_call get"
+ case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
+ case .apiGetNetworkStatuses: return "/_network_statuses"
+ case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))"
+ case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
+ case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
+ case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
+ case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
+ case let .cancelFile(fileId): return "/fcancel \(fileId)"
+ case let .setLocalDeviceName(displayName): return "/set device name \(displayName)"
+ case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)"
+ case .findKnownRemoteCtrl: return "/find remote ctrl"
+ case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)"
+ case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)"
+ case .listRemoteCtrls: return "/list remote ctrls"
+ case .stopRemoteCtrl: return "/stop remote ctrl"
+ case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)"
+ case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)"
+ case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
+ case let .apiStandaloneFileInfo(link): return "/_download info \(link)"
+ case .showVersion: return "/version"
+ case let .getAgentSubsTotal(userId): return "/get subs total \(userId)"
+ case let .getAgentServersSummary(userId): return "/get servers summary \(userId)"
+ case .resetAgentServersStats: return "/reset servers stats"
+ case let .string(str): return str
+ }
+ }
+ }
+
+ var cmdType: String {
+ get {
+ switch self {
+ case .showActiveUser: return "showActiveUser"
+ case .createActiveUser: return "createActiveUser"
+ case .listUsers: return "listUsers"
+ case .apiSetActiveUser: return "apiSetActiveUser"
+ case .setAllContactReceipts: return "setAllContactReceipts"
+ case .apiSetUserContactReceipts: return "apiSetUserContactReceipts"
+ case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts"
+ case .apiHideUser: return "apiHideUser"
+ case .apiUnhideUser: return "apiUnhideUser"
+ case .apiMuteUser: return "apiMuteUser"
+ case .apiUnmuteUser: return "apiUnmuteUser"
+ case .apiDeleteUser: return "apiDeleteUser"
+ case .startChat: return "startChat"
+ case .checkChatRunning: return "checkChatRunning"
+ case .apiStopChat: return "apiStopChat"
+ case .apiActivateChat: return "apiActivateChat"
+ case .apiSuspendChat: return "apiSuspendChat"
+ case .apiSetAppFilePaths: return "apiSetAppFilePaths"
+ case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
+ case .apiExportArchive: return "apiExportArchive"
+ case .apiImportArchive: return "apiImportArchive"
+ case .apiDeleteStorage: return "apiDeleteStorage"
+ case .apiStorageEncryption: return "apiStorageEncryption"
+ case .testStorageEncryption: return "testStorageEncryption"
+ case .apiSaveSettings: return "apiSaveSettings"
+ case .apiGetSettings: return "apiGetSettings"
+ case .apiGetChatTags: return "apiGetChatTags"
+ case .apiGetChats: return "apiGetChats"
+ case .apiGetChat: return "apiGetChat"
+ case .apiGetChatItemInfo: return "apiGetChatItemInfo"
+ case .apiSendMessages: return "apiSendMessages"
+ case .apiCreateChatTag: return "apiCreateChatTag"
+ case .apiSetChatTags: return "apiSetChatTags"
+ case .apiDeleteChatTag: return "apiDeleteChatTag"
+ case .apiUpdateChatTag: return "apiUpdateChatTag"
+ case .apiReorderChatTags: return "apiReorderChatTags"
+ case .apiCreateChatItems: return "apiCreateChatItems"
+ case .apiReportMessage: return "apiReportMessage"
+ case .apiUpdateChatItem: return "apiUpdateChatItem"
+ case .apiDeleteChatItem: return "apiDeleteChatItem"
+ case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
+ case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
+ case .apiArchiveReceivedReports: return "apiArchiveReceivedReports"
+ case .apiDeleteReceivedReports: return "apiDeleteReceivedReports"
+ case .apiChatItemReaction: return "apiChatItemReaction"
+ case .apiGetReactionMembers: return "apiGetReactionMembers"
+ case .apiPlanForwardChatItems: return "apiPlanForwardChatItems"
+ case .apiForwardChatItems: return "apiForwardChatItems"
+ case .apiGetNtfToken: return "apiGetNtfToken"
+ case .apiRegisterToken: return "apiRegisterToken"
+ case .apiVerifyToken: return "apiVerifyToken"
+ case .apiCheckToken: return "apiCheckToken"
+ case .apiDeleteToken: return "apiDeleteToken"
+ case .apiGetNtfConns: return "apiGetNtfConns"
+ case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
+ case .apiNewGroup: return "apiNewGroup"
+ case .apiAddMember: return "apiAddMember"
+ case .apiJoinGroup: return "apiJoinGroup"
+ case .apiMembersRole: return "apiMembersRole"
+ case .apiBlockMembersForAll: return "apiBlockMembersForAll"
+ case .apiRemoveMembers: return "apiRemoveMembers"
+ case .apiLeaveGroup: return "apiLeaveGroup"
+ case .apiListMembers: return "apiListMembers"
+ case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
+ case .apiCreateGroupLink: return "apiCreateGroupLink"
+ case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
+ case .apiDeleteGroupLink: return "apiDeleteGroupLink"
+ case .apiGetGroupLink: return "apiGetGroupLink"
+ case .apiCreateMemberContact: return "apiCreateMemberContact"
+ case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
+ case .apiTestProtoServer: return "apiTestProtoServer"
+ case .apiGetServerOperators: return "apiGetServerOperators"
+ case .apiSetServerOperators: return "apiSetServerOperators"
+ case .apiGetUserServers: return "apiGetUserServers"
+ case .apiSetUserServers: return "apiSetUserServers"
+ case .apiValidateServers: return "apiValidateServers"
+ case .apiGetUsageConditions: return "apiGetUsageConditions"
+ case .apiSetConditionsNotified: return "apiSetConditionsNotified"
+ case .apiAcceptConditions: return "apiAcceptConditions"
+ case .apiSetChatItemTTL: return "apiSetChatItemTTL"
+ case .apiGetChatItemTTL: return "apiGetChatItemTTL"
+ case .apiSetChatTTL: return "apiSetChatTTL"
+ case .apiSetNetworkConfig: return "apiSetNetworkConfig"
+ case .apiGetNetworkConfig: return "apiGetNetworkConfig"
+ case .apiSetNetworkInfo: return "apiSetNetworkInfo"
+ case .reconnectAllServers: return "reconnectAllServers"
+ case .reconnectServer: return "reconnectServer"
+ case .apiSetChatSettings: return "apiSetChatSettings"
+ case .apiSetMemberSettings: return "apiSetMemberSettings"
+ case .apiContactInfo: return "apiContactInfo"
+ case .apiGroupMemberInfo: return "apiGroupMemberInfo"
+ case .apiContactQueueInfo: return "apiContactQueueInfo"
+ case .apiGroupMemberQueueInfo: return "apiGroupMemberQueueInfo"
+ case .apiSwitchContact: return "apiSwitchContact"
+ case .apiSwitchGroupMember: return "apiSwitchGroupMember"
+ case .apiAbortSwitchContact: return "apiAbortSwitchContact"
+ case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember"
+ case .apiSyncContactRatchet: return "apiSyncContactRatchet"
+ case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet"
+ case .apiGetContactCode: return "apiGetContactCode"
+ case .apiGetGroupMemberCode: return "apiGetGroupMemberCode"
+ case .apiVerifyContact: return "apiVerifyContact"
+ case .apiVerifyGroupMember: return "apiVerifyGroupMember"
+ case .apiAddContact: return "apiAddContact"
+ case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
+ case .apiChangeConnectionUser: return "apiChangeConnectionUser"
+ case .apiConnectPlan: return "apiConnectPlan"
+ case .apiConnect: return "apiConnect"
+ case .apiDeleteChat: return "apiDeleteChat"
+ case .apiClearChat: return "apiClearChat"
+ case .apiListContacts: return "apiListContacts"
+ case .apiUpdateProfile: return "apiUpdateProfile"
+ case .apiSetContactPrefs: return "apiSetContactPrefs"
+ case .apiSetContactAlias: return "apiSetContactAlias"
+ case .apiSetGroupAlias: return "apiSetGroupAlias"
+ case .apiSetConnectionAlias: return "apiSetConnectionAlias"
+ case .apiSetUserUIThemes: return "apiSetUserUIThemes"
+ case .apiSetChatUIThemes: return "apiSetChatUIThemes"
+ case .apiCreateMyAddress: return "apiCreateMyAddress"
+ case .apiDeleteMyAddress: return "apiDeleteMyAddress"
+ case .apiShowMyAddress: return "apiShowMyAddress"
+ case .apiSetProfileAddress: return "apiSetProfileAddress"
+ case .apiAddressAutoAccept: return "apiAddressAutoAccept"
+ case .apiAcceptContact: return "apiAcceptContact"
+ case .apiRejectContact: return "apiRejectContact"
+ case .apiSendCallInvitation: return "apiSendCallInvitation"
+ case .apiRejectCall: return "apiRejectCall"
+ case .apiSendCallOffer: return "apiSendCallOffer"
+ case .apiSendCallAnswer: return "apiSendCallAnswer"
+ case .apiSendCallExtraInfo: return "apiSendCallExtraInfo"
+ case .apiEndCall: return "apiEndCall"
+ case .apiGetCallInvitations: return "apiGetCallInvitations"
+ case .apiCallStatus: return "apiCallStatus"
+ case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
+ case .apiChatRead: return "apiChatRead"
+ case .apiChatItemsRead: return "apiChatItemsRead"
+ case .apiChatUnread: return "apiChatUnread"
+ case .receiveFile: return "receiveFile"
+ case .setFileToReceive: return "setFileToReceive"
+ case .cancelFile: return "cancelFile"
+ case .setLocalDeviceName: return "setLocalDeviceName"
+ case .connectRemoteCtrl: return "connectRemoteCtrl"
+ case .findKnownRemoteCtrl: return "findKnownRemoteCtrl"
+ case .confirmRemoteCtrl: return "confirmRemoteCtrl"
+ case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession"
+ case .listRemoteCtrls: return "listRemoteCtrls"
+ case .stopRemoteCtrl: return "stopRemoteCtrl"
+ case .deleteRemoteCtrl: return "deleteRemoteCtrl"
+ case .apiUploadStandaloneFile: return "apiUploadStandaloneFile"
+ case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
+ case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
+ case .showVersion: return "showVersion"
+ case .getAgentSubsTotal: return "getAgentSubsTotal"
+ case .getAgentServersSummary: return "getAgentServersSummary"
+ case .resetAgentServersStats: return "resetAgentServersStats"
+ case .string: return "console command"
+ }
+ }
+ }
+
+ func ref(_ type: ChatType, _ id: Int64) -> String {
+ "\(type.rawValue)\(id)"
+ }
+
+ func joinedIds(_ ids: [Int64]) -> String {
+ ids.map { "\($0)" }.joined(separator: ",")
+ }
+
+ func chatItemTTLStr(seconds: Int64?) -> String {
+ if let seconds = seconds {
+ return String(seconds)
+ } else {
+ return "default"
+ }
+ }
+
+ var obfuscated: ChatCommand {
+ switch self {
+ case let .apiStorageEncryption(cfg):
+ return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey)))
+ case let .apiSetActiveUser(userId, viewPwd):
+ return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd))
+ case let .apiHideUser(userId, viewPwd):
+ return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd))
+ case let .apiUnhideUser(userId, viewPwd):
+ return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd))
+ case let .apiDeleteUser(userId, delSMPQueues, viewPwd):
+ return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
+ case let .testStorageEncryption(key):
+ return .testStorageEncryption(key: obfuscate(key))
+ default: return self
+ }
+ }
+
+ private func obfuscate(_ s: String) -> String {
+ s == "" ? "" : "***"
+ }
+
+ private func obfuscate(_ s: String?) -> String? {
+ if let s = s {
+ return obfuscate(s)
+ }
+ return nil
+ }
+
+ private func onOffParam(_ param: String, _ b: Bool?) -> String {
+ if let b = b {
+ return " \(param)=\(onOff(b))"
+ }
+ return ""
+ }
+
+ private func maybePwd(_ pwd: String?) -> String {
+ pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
+ }
+}
+
+// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
+enum ChatResponse0: Decodable, ChatAPIResult {
+ case activeUser(user: User)
+ case usersList(users: [UserInfo])
+ case chatStarted
+ case chatRunning
+ case chatStopped
+ case apiChats(user: UserRef, chats: [ChatData])
+ case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?)
+ case chatTags(user: UserRef, userTags: [ChatTag])
+ case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
+ case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
+ case serverOperatorConditions(conditions: ServerOperatorConditions)
+ case userServers(user: UserRef, userServers: [UserOperatorServers])
+ case userServersValidation(user: UserRef, serverErrors: [UserServersError])
+ case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
+ case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
+ case networkConfig(networkConfig: NetCfg)
+ case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
+ case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?)
+ case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo)
+ case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats)
+ case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats)
+ case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats)
+ case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats)
+ case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats)
+ case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats)
+ case contactCode(user: UserRef, contact: Contact, connectionCode: String)
+ case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String)
+ case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
+ case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64])
+
+ var responseType: String {
+ switch self {
+ case .activeUser: "activeUser"
+ case .usersList: "usersList"
+ case .chatStarted: "chatStarted"
+ case .chatRunning: "chatRunning"
+ case .chatStopped: "chatStopped"
+ case .apiChats: "apiChats"
+ case .apiChat: "apiChat"
+ case .chatTags: "chatTags"
+ case .chatItemInfo: "chatItemInfo"
+ case .serverTestResult: "serverTestResult"
+ case .serverOperatorConditions: "serverOperators"
+ case .userServers: "userServers"
+ case .userServersValidation: "userServersValidation"
+ case .usageConditions: "usageConditions"
+ case .chatItemTTL: "chatItemTTL"
+ case .networkConfig: "networkConfig"
+ case .contactInfo: "contactInfo"
+ case .groupMemberInfo: "groupMemberInfo"
+ case .queueInfo: "queueInfo"
+ case .contactSwitchStarted: "contactSwitchStarted"
+ case .groupMemberSwitchStarted: "groupMemberSwitchStarted"
+ case .contactSwitchAborted: "contactSwitchAborted"
+ case .groupMemberSwitchAborted: "groupMemberSwitchAborted"
+ case .contactRatchetSyncStarted: "contactRatchetSyncStarted"
+ case .groupMemberRatchetSyncStarted: "groupMemberRatchetSyncStarted"
+ case .contactCode: "contactCode"
+ case .groupMemberCode: "groupMemberCode"
+ case .connectionVerified: "connectionVerified"
+ case .tagsUpdated: "tagsUpdated"
+ }
+ }
+
+ var details: String {
+ switch self {
+ case let .activeUser(user): return String(describing: user)
+ case let .usersList(users): return String(describing: users)
+ case .chatStarted: return noDetails
+ case .chatRunning: return noDetails
+ case .chatStopped: return noDetails
+ case let .apiChats(u, chats): return withUser(u, String(describing: chats))
+ case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))")
+ case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
+ case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
+ case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
+ case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
+ case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
+ case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
+ case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
+ case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
+ case let .networkConfig(networkConfig): return String(describing: networkConfig)
+ case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
+ case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))")
+ case let .queueInfo(u, rcvMsgInfo, queueInfo):
+ let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" }
+ return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))")
+ case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))")
+ case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
+ case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
+ case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
+ case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))")
+ }
+ }
+
+ static func fallbackResult(_ type: String, _ json: NSDictionary) -> ChatResponse0? {
+ if type == "apiChats" {
+ if let r = parseApiChats(json) {
+ return .apiChats(user: r.user, chats: r.chats)
+ }
+ } else if type == "apiChat" {
+ if let jApiChat = json["apiChat"] as? NSDictionary,
+ let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
+ let jChat = jApiChat["chat"] as? NSDictionary,
+ let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) {
+ return .apiChat(user: user, chat: chat, navInfo: navInfo)
+ }
+ }
+ return nil
+ }
+}
+
+enum ChatResponse1: Decodable, ChatAPIResult {
+ case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection)
+ case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
+ case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
+ case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan)
+ case sentConfirmation(user: UserRef, connection: PendingContactConnection)
+ case sentInvitation(user: UserRef, connection: PendingContactConnection)
+ case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
+ case contactAlreadyExists(user: UserRef, contact: Contact)
+ case contactDeleted(user: UserRef, contact: Contact)
+ case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
+ case groupDeletedUser(user: UserRef, groupInfo: GroupInfo)
+ case chatCleared(user: UserRef, chatInfo: ChatInfo)
+ case userProfileNoChange(user: User)
+ case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
+ case userPrivacy(user: User, updatedUser: User)
+ case contactAliasUpdated(user: UserRef, toContact: Contact)
+ case groupAliasUpdated(user: UserRef, toGroup: GroupInfo)
+ case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection)
+ case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
+ case userContactLink(user: User, contactLink: UserContactLink)
+ case userContactLinkUpdated(user: User, contactLink: UserContactLink)
+ case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink)
+ case userContactLinkDeleted(user: User)
+ case acceptingContactRequest(user: UserRef, contact: Contact)
+ case contactRequestRejected(user: UserRef)
+ case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
+ case newChatItems(user: UserRef, chatItems: [AChatItem])
+ case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?)
+ case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
+ case chatItemUpdated(user: UserRef, chatItem: AChatItem)
+ case chatItemNotChanged(user: UserRef, chatItem: AChatItem)
+ case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction)
+ case reactionMembers(user: UserRef, memberReactions: [MemberReaction])
+ case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool)
+ case contactsList(user: UserRef, contacts: [Contact])
+
+ var responseType: String {
+ switch self {
+ case .invitation: "invitation"
+ case .connectionIncognitoUpdated: "connectionIncognitoUpdated"
+ case .connectionUserChanged: "connectionUserChanged"
+ case .connectionPlan: "connectionPlan"
+ case .sentConfirmation: "sentConfirmation"
+ case .sentInvitation: "sentInvitation"
+ case .sentInvitationToContact: "sentInvitationToContact"
+ case .contactAlreadyExists: "contactAlreadyExists"
+ case .contactDeleted: "contactDeleted"
+ case .contactConnectionDeleted: "contactConnectionDeleted"
+ case .groupDeletedUser: "groupDeletedUser"
+ case .chatCleared: "chatCleared"
+ case .userProfileNoChange: "userProfileNoChange"
+ case .userProfileUpdated: "userProfileUpdated"
+ case .userPrivacy: "userPrivacy"
+ case .contactAliasUpdated: "contactAliasUpdated"
+ case .groupAliasUpdated: "groupAliasUpdated"
+ case .connectionAliasUpdated: "connectionAliasUpdated"
+ case .contactPrefsUpdated: "contactPrefsUpdated"
+ case .userContactLink: "userContactLink"
+ case .userContactLinkUpdated: "userContactLinkUpdated"
+ case .userContactLinkCreated: "userContactLinkCreated"
+ case .userContactLinkDeleted: "userContactLinkDeleted"
+ case .acceptingContactRequest: "acceptingContactRequest"
+ case .contactRequestRejected: "contactRequestRejected"
+ case .networkStatuses: "networkStatuses"
+ case .newChatItems: "newChatItems"
+ case .groupChatItemsDeleted: "groupChatItemsDeleted"
+ case .forwardPlan: "forwardPlan"
+ case .chatItemUpdated: "chatItemUpdated"
+ case .chatItemNotChanged: "chatItemNotChanged"
+ case .chatItemReaction: "chatItemReaction"
+ case .reactionMembers: "reactionMembers"
+ case .chatItemsDeleted: "chatItemsDeleted"
+ case .contactsList: "contactsList"
+ }
+ }
+
+ var details: String {
+ switch self {
+ case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
+ case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
+ case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
+ case .userProfileNoChange: return noDetails
+ case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
+ case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser))
+ case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
+ case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
+ case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
+ case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")
+ case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails)
+ case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails)
+ case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink))
+ case .userContactLinkDeleted: return noDetails
+ case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact))
+ case .contactRequestRejected: return noDetails
+ case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
+ case let .newChatItems(u, chatItems):
+ let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
+ return withUser(u, itemsString)
+ 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 .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
+ case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
+ case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem))
+ case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))")
+ case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))")
+ case let .chatItemsDeleted(u, items, byUser):
+ let itemsString = items.map { item in
+ "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n")
+ return withUser(u, itemsString + "\nbyUser: \(byUser)")
+ case let .contactsList(u, contacts): return withUser(u, String(describing: contacts))
+ case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)")
+ case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
+ case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))")
+ case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
+ case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
+ case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
+ case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
+ case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
+ }
+ }
+}
+
+enum ChatResponse2: Decodable, ChatAPIResult {
+ // group responses
+ case groupCreated(user: UserRef, groupInfo: GroupInfo)
+ case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
+ case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
+ case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
+ case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
+ case groupMembers(user: UserRef, group: SimpleXChat.Group)
+ case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
+ case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
+ case groupUpdated(user: UserRef, toGroup: GroupInfo)
+ case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole)
+ case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole)
+ case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo)
+ case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
+ case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
+ // receiving file responses
+ case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
+ case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer)
+ case standaloneFileInfo(fileMeta: MigrationFileLinkData?)
+ case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer)
+ // sending file responses
+ case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
+ case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload
+ case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used
+ case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta)
+ // call invitations
+ case callInvitations(callInvitations: [RcvCallInvitation])
+ // notifications
+ case ntfTokenStatus(status: NtfTknStatus)
+ case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
+ case ntfConns(ntfConns: [NtfConn])
+ case connNtfMessages(receivedMsgs: [RcvNtfMsgInfo])
+ // remote desktop responses
+ case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
+ case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
+ case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
+ // misc
+ case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
+ case cmdOk(user_: UserRef?)
+ case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool)
+ case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary)
+ case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs)
+ case archiveExported(archiveErrors: [ArchiveError])
+ case archiveImported(archiveErrors: [ArchiveError])
+ case appSettings(appSettings: AppSettings)
+
+ var responseType: String {
+ switch self {
+ case .groupCreated: "groupCreated"
+ case .sentGroupInvitation: "sentGroupInvitation"
+ case .userAcceptedGroupSent: "userAcceptedGroupSent"
+ case .userDeletedMembers: "userDeletedMembers"
+ case .leftMemberUser: "leftMemberUser"
+ case .groupMembers: "groupMembers"
+ case .membersRoleUser: "membersRoleUser"
+ case .membersBlockedForAllUser: "membersBlockedForAllUser"
+ case .groupUpdated: "groupUpdated"
+ case .groupLinkCreated: "groupLinkCreated"
+ case .groupLink: "groupLink"
+ case .groupLinkDeleted: "groupLinkDeleted"
+ case .newMemberContact: "newMemberContact"
+ case .newMemberContactSentInv: "newMemberContactSentInv"
+ case .rcvFileAccepted: "rcvFileAccepted"
+ case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
+ case .standaloneFileInfo: "standaloneFileInfo"
+ case .rcvStandaloneFileCreated: "rcvStandaloneFileCreated"
+ case .rcvFileCancelled: "rcvFileCancelled"
+ case .sndFileCancelled: "sndFileCancelled"
+ case .sndStandaloneFileCreated: "sndStandaloneFileCreated"
+ case .sndFileStartXFTP: "sndFileStartXFTP"
+ case .sndFileCancelledXFTP: "sndFileCancelledXFTP"
+ case .callInvitations: "callInvitations"
+ case .ntfTokenStatus: "ntfTokenStatus"
+ case .ntfToken: "ntfToken"
+ case .ntfConns: "ntfConns"
+ case .connNtfMessages: "connNtfMessages"
+ case .remoteCtrlList: "remoteCtrlList"
+ case .remoteCtrlConnecting: "remoteCtrlConnecting"
+ case .remoteCtrlConnected: "remoteCtrlConnected"
+ case .versionInfo: "versionInfo"
+ case .cmdOk: "cmdOk"
+ case .agentSubsTotal: "agentSubsTotal"
+ case .agentServersSummary: "agentServersSummary"
+ case .agentSubsSummary: "agentSubsSummary"
+ case .archiveExported: "archiveExported"
+ case .archiveImported: "archiveImported"
+ case .appSettings: "appSettings"
+ }
+ }
+
+ var details: String {
+ switch self {
+ case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
+ case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
+ case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
+ case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .groupMembers(u, group): return withUser(u, String(describing: group))
+ case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
+ case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
+ case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
+ case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)")
+ case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)")
+ case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
+ case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
+ case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
+ case .rcvFileAcceptedSndCancelled: return noDetails
+ case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta)
+ case .rcvStandaloneFileCreated: return noDetails
+ case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
+ case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem))
+ case .sndStandaloneFileCreated: return noDetails
+ case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
+ case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
+ case let .callInvitations(invs): return String(describing: invs)
+ case let .ntfTokenStatus(status): return String(describing: status)
+ case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
+ case let .ntfConns(ntfConns): return String(describing: ntfConns)
+ case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))"
+ case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
+ case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)"
+ case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl)
+ case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))"
+ case .cmdOk: return noDetails
+ case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)")
+ case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary))
+ case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary))
+ case let .archiveExported(archiveErrors): return String(describing: archiveErrors)
+ case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
+ case let .appSettings(appSettings): return String(describing: appSettings)
+ }
+ }
+}
+
+enum ChatEvent: Decodable, ChatAPIResult {
+ case chatSuspended
+ case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress)
+ case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress)
+ case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress)
+ case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress)
+ case contactDeletedByContact(user: UserRef, contact: Contact)
+ case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
+ case contactConnecting(user: UserRef, contact: Contact)
+ case contactSndReady(user: UserRef, contact: Contact)
+ case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
+ case contactUpdated(user: UserRef, toContact: Contact)
+ case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
+ case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact)
+ case networkStatus(networkStatus: NetworkStatus, connections: [String])
+ case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
+ case newChatItems(user: UserRef, chatItems: [AChatItem])
+ case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
+ case chatItemUpdated(user: UserRef, chatItem: AChatItem)
+ case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction)
+ case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool)
+ // group events
+ case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?)
+ case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, 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)
+ case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember)
+ case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole)
+ case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool)
+ case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool)
+ case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool)
+ case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case userJoinedGroup(user: UserRef, groupInfo: GroupInfo)
+ case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?)
+ case groupUpdated(user: UserRef, toGroup: GroupInfo)
+ case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
+ // receiving file events
+ case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
+ case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats
+ case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileComplete(user: UserRef, chatItem: AChatItem)
+ case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer)
+ case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer)
+ // sending file events
+ case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
+ case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64)
+ case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta)
+ case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta)
+ case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String])
+ case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String)
+ case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String)
+ // call events
+ case callInvitation(callInvitation: RcvCallInvitation)
+ case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool)
+ case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession)
+ case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo)
+ case callEnded(user: UserRef, contact: Contact)
+ case contactDisabled(user: UserRef, contact: Contact)
+ // notification marker
+ case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo)
+ // remote desktop responses
+ case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool)
+ case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
+ case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
+ case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
+ // pq
+ case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool)
+
+ var responseType: String {
+ switch self {
+ case .chatSuspended: "chatSuspended"
+ case .contactSwitch: "contactSwitch"
+ case .groupMemberSwitch: "groupMemberSwitch"
+ case .contactRatchetSync: "contactRatchetSync"
+ case .groupMemberRatchetSync: "groupMemberRatchetSync"
+ case .contactDeletedByContact: "contactDeletedByContact"
+ case .contactConnected: "contactConnected"
+ case .contactConnecting: "contactConnecting"
+ case .contactSndReady: "contactSndReady"
+ case .receivedContactRequest: "receivedContactRequest"
+ case .contactUpdated: "contactUpdated"
+ case .groupMemberUpdated: "groupMemberUpdated"
+ case .contactsMerged: "contactsMerged"
+ case .networkStatus: "networkStatus"
+ case .networkStatuses: "networkStatuses"
+ case .newChatItems: "newChatItems"
+ case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated"
+ case .chatItemUpdated: "chatItemUpdated"
+ case .chatItemReaction: "chatItemReaction"
+ case .chatItemsDeleted: "chatItemsDeleted"
+ case .groupChatItemsDeleted: "groupChatItemsDeleted"
+ case .receivedGroupInvitation: "receivedGroupInvitation"
+ case .userAcceptedGroupSent: "userAcceptedGroupSent"
+ case .groupLinkConnecting: "groupLinkConnecting"
+ case .businessLinkConnecting: "businessLinkConnecting"
+ case .joinedGroupMemberConnecting: "joinedGroupMemberConnecting"
+ case .memberRole: "memberRole"
+ case .memberBlockedForAll: "memberBlockedForAll"
+ case .deletedMemberUser: "deletedMemberUser"
+ case .deletedMember: "deletedMember"
+ case .leftMember: "leftMember"
+ case .groupDeleted: "groupDeleted"
+ case .userJoinedGroup: "userJoinedGroup"
+ case .joinedGroupMember: "joinedGroupMember"
+ case .connectedToGroupMember: "connectedToGroupMember"
+ case .groupUpdated: "groupUpdated"
+ case .newMemberContactReceivedInv: "newMemberContactReceivedInv"
+ case .rcvFileAccepted: "rcvFileAccepted"
+ case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
+ case .rcvFileStart: "rcvFileStart"
+ case .rcvFileProgressXFTP: "rcvFileProgressXFTP"
+ case .rcvFileComplete: "rcvFileComplete"
+ case .rcvStandaloneFileComplete: "rcvStandaloneFileComplete"
+ case .rcvFileSndCancelled: "rcvFileSndCancelled"
+ case .rcvFileError: "rcvFileError"
+ case .rcvFileWarning: "rcvFileWarning"
+ case .sndFileStart: "sndFileStart"
+ case .sndFileComplete: "sndFileComplete"
+ case .sndFileRcvCancelled: "sndFileRcvCancelled"
+ case .sndFileProgressXFTP: "sndFileProgressXFTP"
+ case .sndFileRedirectStartXFTP: "sndFileRedirectStartXFTP"
+ case .sndFileCompleteXFTP: "sndFileCompleteXFTP"
+ case .sndStandaloneFileComplete: "sndStandaloneFileComplete"
+ case .sndFileError: "sndFileError"
+ case .sndFileWarning: "sndFileWarning"
+ case .callInvitation: "callInvitation"
+ case .callOffer: "callOffer"
+ case .callAnswer: "callAnswer"
+ case .callExtraInfo: "callExtraInfo"
+ case .callEnded: "callEnded"
+ case .contactDisabled: "contactDisabled"
+ case .ntfMessage: "ntfMessage"
+ case .remoteCtrlFound: "remoteCtrlFound"
+ case .remoteCtrlSessionCode: "remoteCtrlSessionCode"
+ case .remoteCtrlConnected: "remoteCtrlConnected"
+ case .remoteCtrlStopped: "remoteCtrlStopped"
+ case .contactPQEnabled: "contactPQEnabled"
+ }
+ }
+
+ var details: String {
+ switch self {
+ case .chatSuspended: return noDetails
+ case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))")
+ case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))")
+ case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))")
+ case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))")
+ case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact))
+ case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact))
+ case let .contactConnecting(u, contact): return withUser(u, String(describing: contact))
+ case let .contactSndReady(u, contact): return withUser(u, String(describing: contact))
+ case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest))
+ case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
+ case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)")
+ case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)")
+ case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
+ case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
+ case let .newChatItems(u, chatItems):
+ let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
+ return withUser(u, itemsString)
+ case let .chatItemsStatusesUpdated(u, chatItems):
+ let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
+ return withUser(u, itemsString)
+ case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
+ case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))")
+ case let .chatItemsDeleted(u, items, byUser):
+ let itemsString = items.map { item in
+ "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n")
+ return withUser(u, itemsString + "\nbyUser: \(byUser)")
+ 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 .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))")
+ case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)")
+ case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)")
+ case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)")
+ case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)")
+ case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)")
+ case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))")
+ case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
+ case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
+ case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
+ case .rcvFileAcceptedSndCancelled: return noDetails
+ case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem))
+ case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)")
+ case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath)
+ case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem))
+ case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
+ case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))")
+ case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))")
+ case let .sndFileStart(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))
+ case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)")
+ case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta))
+ case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
+ case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count))
+ case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))")
+ case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))")
+ case let .callInvitation(inv): return String(describing: inv)
+ case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))")
+ case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))")
+ case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))")
+ case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
+ case let .contactDisabled(u, contact): return withUser(u, String(describing: contact))
+ case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
+ case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)"
+ case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)"
+ case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl)
+ case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))"
+ case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
+ }
+ }
+}
+
+struct NewUser: Encodable {
+ var profile: Profile?
+ var pastTimestamp: Bool
+}
+
+enum ChatPagination {
+ static let INITIAL_COUNT = 75
+ static let PRELOAD_COUNT = 100
+ static let UNTIL_PRELOAD_COUNT = 50
+
+ case last(count: Int)
+ case after(chatItemId: Int64, count: Int)
+ case before(chatItemId: Int64, count: Int)
+ case around(chatItemId: Int64, count: Int)
+ case initial(count: Int)
+
+ var cmdString: String {
+ switch self {
+ case let .last(count): return "count=\(count)"
+ case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
+ case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
+ case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
+ case let .initial(count): return "initial=\(count)"
+ }
+ }
+}
+
+enum ConnectionPlan: Decodable, Hashable {
+ case invitationLink(invitationLinkPlan: InvitationLinkPlan)
+ case contactAddress(contactAddressPlan: ContactAddressPlan)
+ case groupLink(groupLinkPlan: GroupLinkPlan)
+ case error(chatError: ChatError)
+}
+
+enum InvitationLinkPlan: Decodable, Hashable {
+ case ok
+ case ownLink
+ case connecting(contact_: Contact?)
+ case known(contact: Contact)
+}
+
+enum ContactAddressPlan: Decodable, Hashable {
+ case ok
+ case ownLink
+ case connectingConfirmReconnect
+ case connectingProhibit(contact: Contact)
+ case known(contact: Contact)
+ case contactViaAddress(contact: Contact)
+}
+
+enum GroupLinkPlan: Decodable, Hashable {
+ case ok
+ case ownLink(groupInfo: GroupInfo)
+ case connectingConfirmReconnect
+ case connectingProhibit(groupInfo_: GroupInfo?)
+ case known(groupInfo: GroupInfo)
+}
+
+struct ChatTagData: Encodable {
+ var emoji: String?
+ var text: String
+}
+
+struct UpdatedMessage: Encodable {
+ var msgContent: MsgContent
+ var mentions: [String: Int64]
+
+ var cmdString: String {
+ "json \(encodeJSON(self))"
+ }
+}
+
+enum ChatDeleteMode: Codable {
+ case full(notify: Bool)
+ case entity(notify: Bool)
+ case messages
+
+ var cmdString: String {
+ switch self {
+ case let .full(notify): "full notify=\(onOff(notify))"
+ case let .entity(notify): "entity notify=\(onOff(notify))"
+ case .messages: "messages"
+ }
+ }
+
+ var isEntity: Bool {
+ switch self {
+ case .entity: return true
+ default: return false
+ }
+ }
+}
+
+enum NetworkStatus: Decodable, Equatable {
+ case unknown
+ case connected
+ case disconnected
+ case error(connectionError: String)
+
+ var statusString: LocalizedStringKey {
+ switch self {
+ case .connected: "connected"
+ case .error: "error"
+ default: "connecting"
+ }
+ }
+
+ var statusExplanation: LocalizedStringKey {
+ switch self {
+ case .connected: "You are connected to the server used to receive messages from this contact."
+ case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))."
+ default: "Trying to connect to the server used to receive messages from this contact."
+ }
+ }
+
+ var imageName: String {
+ switch self {
+ case .unknown: "circle.dotted"
+ case .connected: "circle.fill"
+ case .disconnected: "ellipsis.circle.fill"
+ case .error: "exclamationmark.circle.fill"
+ }
+ }
+}
+
+enum ForwardConfirmation: Decodable, Hashable {
+ case filesNotAccepted(fileIds: [Int64])
+ case filesInProgress(filesCount: Int)
+ case filesMissing(filesCount: Int)
+ case filesFailed(filesCount: Int)
+}
+
+struct ConnNetworkStatus: Decodable {
+ var agentConnId: String
+ var networkStatus: NetworkStatus
+}
+
+struct UserMsgReceiptSettings: Codable {
+ var enable: Bool
+ var clearOverrides: Bool
+}
+
+
+struct UserContactLink: Decodable, Hashable {
+ var connLinkContact: CreatedConnLink
+ var autoAccept: AutoAccept?
+
+ var responseDetails: String {
+ "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))"
+ }
+}
+
+struct AutoAccept: Codable, Hashable {
+ var businessAddress: Bool
+ var acceptIncognito: Bool
+ var autoReply: MsgContent?
+
+ static func cmdString(_ autoAccept: AutoAccept?) -> String {
+ guard let autoAccept = autoAccept else { return "off" }
+ var s = "on"
+ if autoAccept.acceptIncognito {
+ s += " incognito=on"
+ } else if autoAccept.businessAddress {
+ s += " business"
+ }
+ guard let msg = autoAccept.autoReply else { return s }
+ return s + " " + msg.cmdString
+ }
+}
+
+struct DeviceToken: Decodable {
+ var pushProvider: PushProvider
+ var token: String
+
+ var cmdString: String {
+ "\(pushProvider) \(token)"
+ }
+}
+
+enum PushEnvironment: String {
+ case development
+ case production
+}
+
+enum PushProvider: String, Decodable {
+ case apns_dev
+ case apns_prod
+
+ init(env: PushEnvironment) {
+ switch env {
+ case .development: self = .apns_dev
+ case .production: self = .apns_prod
+ }
+ }
+}
+
+// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable,
+// and .local for periodic background checks
+enum NotificationsMode: String, Decodable, SelectableItem {
+ case off = "OFF"
+ case periodic = "PERIODIC"
+ case instant = "INSTANT"
+
+ var label: LocalizedStringKey {
+ switch self {
+ case .off: "No push server"
+ case .periodic: "Periodic"
+ case .instant: "Instant"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .off: return "arrow.clockwise"
+ case .periodic: return "timer"
+ case .instant: return "bolt"
+ }
+ }
+
+ var id: String { self.rawValue }
+
+ static var values: [NotificationsMode] = [.instant, .periodic, .off]
+}
+
+struct RemoteCtrlInfo: Decodable {
+ var remoteCtrlId: Int64
+ var ctrlDeviceName: String
+ var sessionState: RemoteCtrlSessionState?
+
+ var deviceViewName: String {
+ ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName
+ }
+}
+
+enum RemoteCtrlSessionState: Decodable {
+ case starting
+ case searching
+ case connecting
+ case pendingConfirmation(sessionCode: String)
+ case connected(sessionCode: String)
+}
+
+enum RemoteCtrlStopReason: Decodable {
+ case discoveryFailed(chatError: ChatError)
+ case connectionFailed(chatError: ChatError)
+ case setupFailed(chatError: ChatError)
+ case disconnected
+}
+
+struct CtrlAppInfo: Decodable {
+ var appVersionRange: AppVersionRange
+ var deviceName: String
+}
+
+struct AppVersionRange: Decodable {
+ var minVersion: String
+ var maxVersion: String
+}
+
+struct CoreVersionInfo: Decodable {
+ var version: String
+ var simplexmqVersion: String
+ var simplexmqCommit: String
+}
+
+struct ArchiveConfig: Encodable {
+ var archivePath: String
+ var disableCompression: Bool?
+}
+
+struct DBEncryptionConfig: Codable {
+ var currentKey: String
+ var newKey: String
+}
+
+enum OperatorTag: String, Codable {
+ case simplex = "simplex"
+ case flux = "flux"
+}
+
+struct ServerOperatorInfo {
+ var description: [String]
+ var website: URL
+ var selfhost: (text: String, link: URL)? = nil
+ var logo: String
+ var largeLogo: String
+ var logoDarkMode: String
+ var largeLogoDarkMode: String
+}
+
+let operatorsInfo: Dictionary = [
+ .simplex: ServerOperatorInfo(
+ description: [
+ "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or identity keys.",
+ "SimpleX Chat Ltd develops the communication software for SimpleX network."
+ ],
+ website: URL(string: "https://simplex.chat")!,
+ logo: "decentralized",
+ largeLogo: "logo",
+ logoDarkMode: "decentralized-light",
+ largeLogoDarkMode: "logo-light"
+ ),
+ .flux: ServerOperatorInfo(
+ description: [
+ "Flux is the largest decentralized cloud, based on a global network of user-operated nodes.",
+ "Flux offers a powerful, scalable, and affordable cutting edge technology platform for all.",
+ "Flux operates servers in SimpleX network to improve its privacy and decentralization."
+ ],
+ website: URL(string: "https://runonflux.com")!,
+ selfhost: (text: "Self-host SimpleX servers on Flux", link: URL(string: "https://home.runonflux.io/apps/marketplace?q=simplex")!),
+ logo: "flux_logo_symbol",
+ largeLogo: "flux_logo",
+ logoDarkMode: "flux_logo_symbol",
+ largeLogoDarkMode: "flux_logo-light"
+ ),
+]
+
+struct UsageConditions: Decodable {
+ var conditionsId: Int64
+ var conditionsCommit: String
+ var notifiedAt: Date?
+ var createdAt: Date
+
+ static var sampleData = UsageConditions(
+ conditionsId: 1,
+ conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c",
+ notifiedAt: nil,
+ createdAt: Date.now
+ )
+}
+
+enum UsageConditionsAction: Decodable {
+ case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool)
+ case accepted(operators: [ServerOperator])
+
+ var showNotice: Bool {
+ switch self {
+ case let .review(_, _, showNotice): showNotice
+ case .accepted: false
+ }
+ }
+}
+
+struct ServerOperatorConditions: Decodable {
+ var serverOperators: [ServerOperator]
+ var currentConditions: UsageConditions
+ var conditionsAction: UsageConditionsAction?
+
+ static var empty = ServerOperatorConditions(
+ serverOperators: [],
+ currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now),
+ conditionsAction: nil
+ )
+}
+
+enum ConditionsAcceptance: Equatable, Codable, Hashable {
+ case accepted(acceptedAt: Date?, autoAccepted: Bool)
+ // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
+ // No deadline indicates it's required to accept conditions for the operator to start using it.
+ case required(deadline: Date?)
+
+ var conditionsAccepted: Bool {
+ switch self {
+ case .accepted: true
+ case .required: false
+ }
+ }
+
+ var usageAllowed: Bool {
+ switch self {
+ case .accepted: true
+ case let .required(deadline): deadline != nil
+ }
+ }
+}
+
+struct ServerOperator: Identifiable, Equatable, Codable {
+ var operatorId: Int64
+ var operatorTag: OperatorTag?
+ var tradeName: String
+ var legalName: String?
+ var serverDomains: [String]
+ var conditionsAcceptance: ConditionsAcceptance
+ var enabled: Bool
+ var smpRoles: ServerRoles
+ var xftpRoles: ServerRoles
+
+ var id: Int64 { operatorId }
+
+ static func == (l: ServerOperator, r: ServerOperator) -> Bool {
+ l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName &&
+ l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled &&
+ l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles
+ }
+
+ var legalName_: String {
+ legalName ?? tradeName
+ }
+
+ var info: ServerOperatorInfo {
+ return if let operatorTag = operatorTag {
+ operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo
+ } else {
+ ServerOperator.dummyOperatorInfo
+ }
+ }
+
+ static let dummyOperatorInfo = ServerOperatorInfo(
+ description: ["Default"],
+ website: URL(string: "https://simplex.chat")!,
+ logo: "decentralized",
+ largeLogo: "logo",
+ logoDarkMode: "decentralized-light",
+ largeLogoDarkMode: "logo-light"
+ )
+
+ func logo(_ colorScheme: ColorScheme) -> String {
+ colorScheme == .light ? info.logo : info.logoDarkMode
+ }
+
+ func largeLogo(_ colorScheme: ColorScheme) -> String {
+ colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode
+ }
+
+ static var sampleData1 = ServerOperator(
+ operatorId: 1,
+ operatorTag: .simplex,
+ tradeName: "SimpleX Chat",
+ legalName: "SimpleX Chat Ltd",
+ serverDomains: ["simplex.im"],
+ conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
+ enabled: true,
+ smpRoles: ServerRoles(storage: true, proxy: true),
+ xftpRoles: ServerRoles(storage: true, proxy: true)
+ )
+}
+
+struct ServerRoles: Equatable, Codable {
+ var storage: Bool
+ var proxy: Bool
+}
+
+struct UserOperatorServers: Identifiable, Equatable, Codable {
+ var `operator`: ServerOperator?
+ var smpServers: [UserServer]
+ var xftpServers: [UserServer]
+
+ var id: String {
+ if let op = self.operator {
+ "\(op.operatorId)"
+ } else {
+ "nil operator"
+ }
+ }
+
+ var operator_: ServerOperator {
+ get {
+ self.operator ?? ServerOperator(
+ operatorId: 0,
+ operatorTag: nil,
+ tradeName: "",
+ legalName: "",
+ serverDomains: [],
+ conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
+ enabled: false,
+ smpRoles: ServerRoles(storage: true, proxy: true),
+ xftpRoles: ServerRoles(storage: true, proxy: true)
+ )
+ }
+ set { `operator` = newValue }
+ }
+
+ static var sampleData1 = UserOperatorServers(
+ operator: ServerOperator.sampleData1,
+ smpServers: [UserServer.sampleData.preset],
+ xftpServers: [UserServer.sampleData.xftpPreset]
+ )
+
+ static var sampleDataNilOperator = UserOperatorServers(
+ operator: nil,
+ smpServers: [UserServer.sampleData.preset],
+ xftpServers: [UserServer.sampleData.xftpPreset]
+ )
+}
+
+enum UserServersError: Decodable {
+ case noServers(protocol: ServerProtocol, user: UserRef?)
+ case storageMissing(protocol: ServerProtocol, user: UserRef?)
+ case proxyMissing(protocol: ServerProtocol, user: UserRef?)
+ case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
+
+ var globalError: String? {
+ switch self {
+ case let .noServers(`protocol`, _):
+ switch `protocol` {
+ case .smp: return globalSMPError
+ case .xftp: return globalXFTPError
+ }
+ case let .storageMissing(`protocol`, _):
+ switch `protocol` {
+ case .smp: return globalSMPError
+ case .xftp: return globalXFTPError
+ }
+ case let .proxyMissing(`protocol`, _):
+ switch `protocol` {
+ case .smp: return globalSMPError
+ case .xftp: return globalXFTPError
+ }
+ default: return nil
+ }
+ }
+
+ var globalSMPError: String? {
+ switch self {
+ case let .noServers(.smp, user):
+ let text = NSLocalizedString("No message servers.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ case let .storageMissing(.smp, user):
+ let text = NSLocalizedString("No servers to receive messages.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ case let .proxyMissing(.smp, user):
+ let text = NSLocalizedString("No servers for private message routing.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ default:
+ return nil
+ }
+ }
+
+ var globalXFTPError: String? {
+ switch self {
+ case let .noServers(.xftp, user):
+ let text = NSLocalizedString("No media & file servers.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ case let .storageMissing(.xftp, user):
+ let text = NSLocalizedString("No servers to send files.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ case let .proxyMissing(.xftp, user):
+ let text = NSLocalizedString("No servers to receive files.", comment: "servers error")
+ if let user = user {
+ return userStr(user) + " " + text
+ } else {
+ return text
+ }
+ default:
+ return nil
+ }
+ }
+
+ private func userStr(_ user: UserRef) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName)
+ }
+}
+
+struct UserServer: Identifiable, Equatable, Codable, Hashable {
+ var serverId: Int64?
+ var server: String
+ var preset: Bool
+ var tested: Bool?
+ var enabled: Bool
+ var deleted: Bool
+ var createdAt = Date()
+
+ static func == (l: UserServer, r: UserServer) -> Bool {
+ l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
+ l.enabled == r.enabled && l.deleted == r.deleted
+ }
+
+ var id: String { "\(server) \(createdAt)" }
+
+ static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false)
+
+ var isEmpty: Bool {
+ server.trimmingCharacters(in: .whitespaces) == ""
+ }
+
+ struct SampleData {
+ var preset: UserServer
+ var custom: UserServer
+ var untested: UserServer
+ var xftpPreset: UserServer
+ }
+
+ static var sampleData = SampleData(
+ preset: UserServer(
+ serverId: 1,
+ server: "smp://abcd@smp8.simplex.im",
+ preset: true,
+ tested: true,
+ enabled: true,
+ deleted: false
+ ),
+ custom: UserServer(
+ serverId: 2,
+ server: "smp://abcd@smp9.simplex.im",
+ preset: false,
+ tested: false,
+ enabled: false,
+ deleted: false
+ ),
+ untested: UserServer(
+ serverId: 3,
+ server: "smp://abcd@smp10.simplex.im",
+ preset: false,
+ tested: nil,
+ enabled: true,
+ deleted: false
+ ),
+ xftpPreset: UserServer(
+ serverId: 4,
+ server: "xftp://abcd@xftp8.simplex.im",
+ preset: true,
+ tested: true,
+ enabled: true,
+ deleted: false
+ )
+ )
+
+ enum CodingKeys: CodingKey {
+ case serverId
+ case server
+ case preset
+ case tested
+ case enabled
+ case deleted
+ }
+}
+
+enum ProtocolTestStep: String, Decodable, Equatable {
+ case connect
+ case disconnect
+ case createQueue
+ case secureQueue
+ case deleteQueue
+ case createFile
+ case uploadFile
+ case downloadFile
+ case compareFile
+ case deleteFile
+
+ var text: String {
+ switch self {
+ case .connect: return NSLocalizedString("Connect", comment: "server test step")
+ case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
+ case .createQueue: return NSLocalizedString("Create queue", comment: "server test step")
+ case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step")
+ case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step")
+ case .createFile: return NSLocalizedString("Create file", comment: "server test step")
+ case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step")
+ case .downloadFile: return NSLocalizedString("Download file", comment: "server test step")
+ case .compareFile: return NSLocalizedString("Compare file", comment: "server test step")
+ case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step")
+ }
+ }
+}
+
+struct ProtocolTestFailure: Decodable, Error, Equatable {
+ var testStep: ProtocolTestStep
+ var testError: AgentErrorType
+
+ static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool {
+ l.testStep == r.testStep
+ }
+
+ var localizedDescription: String {
+ let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text)
+ switch testError {
+ case .SMP(_, .AUTH):
+ return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error")
+ case .XFTP(.AUTH):
+ return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error")
+ case .BROKER(_, .NETWORK):
+ return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
+ default:
+ return err
+ }
+ }
+}
+
+struct MigrationFileLinkData: Codable {
+ let networkConfig: NetworkConfig?
+
+ struct NetworkConfig: Codable {
+ let socksProxy: String?
+ let networkProxy: NetworkProxy?
+ let hostMode: HostMode?
+ let requiredHostMode: Bool?
+
+ func transformToPlatformSupported() -> NetworkConfig {
+ return if let hostMode, let requiredHostMode {
+ NetworkConfig(
+ socksProxy: nil,
+ networkProxy: nil,
+ hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode,
+ requiredHostMode: requiredHostMode
+ )
+ } else { self }
+ }
+ }
+
+ func addToLink(link: String) -> String {
+ "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)"
+ }
+
+ static func readFromLink(link: String) -> MigrationFileLinkData? {
+// standaloneFileInfo(link)
+ nil
+ }
+}
+
+struct AppSettings: Codable, Equatable {
+ var networkConfig: NetCfg? = nil
+ var networkProxy: NetworkProxy? = nil
+ var privacyEncryptLocalFiles: Bool? = nil
+ var privacyAskToApproveRelays: Bool? = nil
+ var privacyAcceptImages: Bool? = nil
+ var privacyLinkPreviews: Bool? = nil
+ var privacyShowChatPreviews: Bool? = nil
+ var privacySaveLastDraft: Bool? = nil
+ var privacyProtectScreen: Bool? = nil
+ var privacyMediaBlurRadius: Int? = nil
+ var notificationMode: AppSettingsNotificationMode? = nil
+ var notificationPreviewMode: NotificationPreviewMode? = nil
+ var webrtcPolicyRelay: Bool? = nil
+ var webrtcICEServers: [String]? = nil
+ var confirmRemoteSessions: Bool? = nil
+ var connectRemoteViaMulticast: Bool? = nil
+ var connectRemoteViaMulticastAuto: Bool? = nil
+ var developerTools: Bool? = nil
+ var confirmDBUpgrades: Bool? = nil
+ var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
+ var iosCallKitEnabled: Bool? = nil
+ var iosCallKitCallsInRecents: Bool? = nil
+ var uiProfileImageCornerRadius: Double? = nil
+ var uiChatItemRoundness: Double? = nil
+ var uiChatItemTail: Bool? = nil
+ var uiColorScheme: String? = nil
+ var uiDarkColorScheme: String? = nil
+ var uiCurrentThemeIds: [String: String]? = nil
+ var uiThemes: [ThemeOverrides]? = nil
+ var oneHandUI: Bool? = nil
+ var chatBottomBar: Bool? = nil
+
+ func prepareForExport() -> AppSettings {
+ var empty = AppSettings()
+ let def = AppSettings.defaults
+ if networkConfig != def.networkConfig { empty.networkConfig = networkConfig }
+ if networkProxy != def.networkProxy { empty.networkProxy = networkProxy }
+ if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
+ if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
+ if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
+ if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
+ if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
+ if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft }
+ if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen }
+ if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius }
+ if notificationMode != def.notificationMode { empty.notificationMode = notificationMode }
+ if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode }
+ if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay }
+ if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers }
+ if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions }
+ if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast }
+ if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto }
+ if developerTools != def.developerTools { empty.developerTools = developerTools }
+ if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades }
+ if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
+ if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
+ if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
+ if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius }
+ if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness }
+ if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail }
+ if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme }
+ if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme }
+ if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds }
+ if uiThemes != def.uiThemes { empty.uiThemes = uiThemes }
+ if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
+ if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar }
+ return empty
+ }
+
+ static var defaults: AppSettings {
+ AppSettings (
+ networkConfig: NetCfg.defaults,
+ networkProxy: NetworkProxy.def,
+ privacyEncryptLocalFiles: true,
+ privacyAskToApproveRelays: true,
+ privacyAcceptImages: true,
+ privacyLinkPreviews: true,
+ privacyShowChatPreviews: true,
+ privacySaveLastDraft: true,
+ privacyProtectScreen: false,
+ privacyMediaBlurRadius: 0,
+ notificationMode: AppSettingsNotificationMode.instant,
+ notificationPreviewMode: NotificationPreviewMode.message,
+ webrtcPolicyRelay: true,
+ webrtcICEServers: [],
+ confirmRemoteSessions: false,
+ connectRemoteViaMulticast: true,
+ connectRemoteViaMulticastAuto: true,
+ developerTools: false,
+ confirmDBUpgrades: false,
+ androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
+ iosCallKitEnabled: true,
+ iosCallKitCallsInRecents: false,
+ uiProfileImageCornerRadius: 22.5,
+ uiChatItemRoundness: 0.75,
+ uiChatItemTail: true,
+ uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME,
+ uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName,
+ uiCurrentThemeIds: nil as [String: String]?,
+ uiThemes: nil as [ThemeOverrides]?,
+ oneHandUI: true,
+ chatBottomBar: true
+ )
+ }
+}
+
+enum AppSettingsNotificationMode: String, Codable {
+ case off
+ case periodic
+ case instant
+
+ func toNotificationsMode() -> NotificationsMode {
+ switch self {
+ case .instant: .instant
+ case .periodic: .periodic
+ case .off: .off
+ }
+ }
+
+ static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode {
+ switch mode {
+ case .instant: .instant
+ case .periodic: .periodic
+ case .off: .off
+ }
+ }
+}
+
+//enum NotificationPreviewMode: Codable {
+// case hidden
+// case contact
+// case message
+//}
+
+enum AppSettingsLockScreenCalls: String, Codable {
+ case disable
+ case show
+ case accept
+}
+
+struct UserNetworkInfo: Codable, Equatable {
+ let networkType: UserNetworkType
+ let online: Bool
+}
+
+enum UserNetworkType: String, Codable {
+ case none
+ case cellular
+ case wifi
+ case ethernet
+ case other
+
+ var text: LocalizedStringKey {
+ switch self {
+ case .none: "No network connection"
+ case .cellular: "Cellular"
+ case .wifi: "WiFi"
+ case .ethernet: "Wired ethernet"
+ case .other: "Other"
+ }
+ }
+}
+
+struct RcvMsgInfo: Codable {
+ var msgId: Int64
+ var msgDeliveryId: Int64
+ var msgDeliveryStatus: String
+ var agentMsgId: Int64
+ var agentMsgMeta: String
+}
+
+struct ServerQueueInfo: Codable {
+ var server: String
+ var rcvId: String
+ var sndId: String
+ var ntfId: String?
+ var status: String
+ var info: QueueInfo
+}
+
+struct QueueInfo: Codable {
+ var qiSnd: Bool
+ var qiNtf: Bool
+ var qiSub: QSub?
+ var qiSize: Int
+ var qiMsg: MsgInfo?
+}
+
+struct QSub: Codable {
+ var qSubThread: QSubThread
+ var qDelivered: String?
+}
+
+enum QSubThread: String, Codable {
+ case noSub
+ case subPending
+ case subThread
+ case prohibitSub
+}
+
+struct MsgInfo: Codable {
+ var msgId: String
+ var msgTs: Date
+ var msgType: MsgType
+}
+
+enum MsgType: String, Codable {
+ case message
+ case quota
+}
+
+struct PresentedServersSummary: Codable {
+ var statsStartedAt: Date
+ var allUsersSMP: SMPServersSummary
+ var allUsersXFTP: XFTPServersSummary
+ var currentUserSMP: SMPServersSummary
+ var currentUserXFTP: XFTPServersSummary
+}
+
+struct SMPServersSummary: Codable {
+ var smpTotals: SMPTotals
+ var currentlyUsedSMPServers: [SMPServerSummary]
+ var previouslyUsedSMPServers: [SMPServerSummary]
+ var onlyProxiedSMPServers: [SMPServerSummary]
+}
+
+struct SMPTotals: Codable {
+ var sessions: ServerSessions
+ var subs: SMPServerSubs
+ var stats: AgentSMPServerStatsData
+}
+
+struct SMPServerSummary: Codable, Identifiable {
+ var smpServer: String
+ var known: Bool?
+ var sessions: ServerSessions?
+ var subs: SMPServerSubs?
+ var stats: AgentSMPServerStatsData?
+
+ var id: String { smpServer }
+
+ var hasSubs: Bool { subs != nil }
+
+ var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions }
+
+ var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs }
+}
+
+struct ServerSessions: Codable {
+ var ssConnected: Int
+ var ssErrors: Int
+ var ssConnecting: Int
+
+ static var newServerSessions = ServerSessions(
+ ssConnected: 0,
+ ssErrors: 0,
+ ssConnecting: 0
+ )
+
+ var hasSess: Bool { ssConnected > 0 }
+}
+
+struct SMPServerSubs: Codable {
+ var ssActive: Int
+ var ssPending: Int
+
+ static var newSMPServerSubs = SMPServerSubs(
+ ssActive: 0,
+ ssPending: 0
+ )
+
+ var total: Int { ssActive + ssPending }
+
+ var shareOfActive: Double {
+ guard total != 0 else { return 0.0 }
+ return Double(ssActive) / Double(total)
+ }
+}
+
+struct AgentSMPServerStatsData: Codable {
+ var _sentDirect: Int
+ var _sentViaProxy: Int
+ var _sentProxied: Int
+ var _sentDirectAttempts: Int
+ var _sentViaProxyAttempts: Int
+ var _sentProxiedAttempts: Int
+ var _sentAuthErrs: Int
+ var _sentQuotaErrs: Int
+ var _sentExpiredErrs: Int
+ var _sentOtherErrs: Int
+ var _recvMsgs: Int
+ var _recvDuplicates: Int
+ var _recvCryptoErrs: Int
+ var _recvErrs: Int
+ var _ackMsgs: Int
+ var _ackAttempts: Int
+ var _ackNoMsgErrs: Int
+ var _ackOtherErrs: Int
+ var _connCreated: Int
+ var _connSecured: Int
+ var _connCompleted: Int
+ var _connDeleted: Int
+ var _connDelAttempts: Int
+ var _connDelErrs: Int
+ var _connSubscribed: Int
+ var _connSubAttempts: Int
+ var _connSubIgnored: Int
+ var _connSubErrs: Int
+ var _ntfKey: Int
+ var _ntfKeyAttempts: Int
+ var _ntfKeyDeleted: Int
+ var _ntfKeyDeleteAttempts: Int
+}
+
+struct XFTPServersSummary: Codable {
+ var xftpTotals: XFTPTotals
+ var currentlyUsedXFTPServers: [XFTPServerSummary]
+ var previouslyUsedXFTPServers: [XFTPServerSummary]
+}
+
+struct XFTPTotals: Codable {
+ var sessions: ServerSessions
+ var stats: AgentXFTPServerStatsData
+}
+
+struct XFTPServerSummary: Codable, Identifiable {
+ var xftpServer: String
+ var known: Bool?
+ var sessions: ServerSessions?
+ var stats: AgentXFTPServerStatsData?
+ var rcvInProgress: Bool
+ var sndInProgress: Bool
+ var delInProgress: Bool
+
+ var id: String { xftpServer }
+}
+
+struct AgentXFTPServerStatsData: Codable {
+ var _uploads: Int
+ var _uploadsSize: Int64
+ var _uploadAttempts: Int
+ var _uploadErrs: Int
+ var _downloads: Int
+ var _downloadsSize: Int64
+ var _downloadAttempts: Int
+ var _downloadAuthErrs: Int
+ var _downloadErrs: Int
+ var _deletions: Int
+ var _deleteAttempts: Int
+ var _deleteErrs: Int
+}
+
+struct AgentNtfServerStatsData: Codable {
+ var _ntfCreated: Int
+ var _ntfCreateAttempts: Int
+ var _ntfChecked: Int
+ var _ntfCheckAttempts: Int
+ var _ntfDeleted: Int
+ var _ntfDelAttempts: Int
+}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 0ac1a9cacb..9b9fda0397 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -30,9 +30,18 @@ actor TerminalItems {
}
}
- func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
+ func addCommand(_ start: Date, _ cmd: ChatCommand, _ res: APIResult) async {
await add(.cmd(start, cmd))
- await add(.resp(.now, resp))
+ await addResult(res)
+ }
+
+ func addResult(_ res: APIResult) async {
+ let item: TerminalItem = switch res {
+ case let .result(r): .res(.now, r)
+ case let .error(e): .err(.now, e)
+ case let .invalid(type, json): .bad(.now, type, json)
+ }
+ await add(item)
}
}
@@ -53,51 +62,172 @@ class ItemsModel: ObservableObject {
var itemAdded = false {
willSet { publisher.send() }
}
-
+
+ let chatState = ActiveChatState()
+
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
- @Published var showLoadingProgress = false
+ @Published var showLoadingProgress: ChatId? = nil
+
+ private var navigationTimeoutTask: Task? = nil
+ private var loadChatTask: Task? = nil
+
+ var lastItemsLoaded: Bool {
+ chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
+ }
init() {
publisher
- .throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
+ .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
- let navigationTimeout = Task {
+ navigationTimeoutTask?.cancel()
+ loadChatTask?.cancel()
+ navigationTimeoutTask = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
- willNavigate()
ChatModel.shared.chatId = chatId
+ willNavigate()
}
} catch {}
}
- let progressTimeout = Task {
- do {
- try await Task.sleep(nanoseconds: 1500_000000)
- await MainActor.run { showLoadingProgress = true }
- } catch {}
- }
- Task {
- if let chat = ChatModel.shared.getChat(chatId) {
- await MainActor.run { self.isLoading = true }
-// try? await Task.sleep(nanoseconds: 5000_000000)
- await loadChat(chat: chat)
- navigationTimeout.cancel()
- progressTimeout.cancel()
+ loadChatTask = Task {
+ await MainActor.run { self.isLoading = true }
+// try? await Task.sleep(nanoseconds: 1000_000000)
+ await loadChat(chatId: chatId)
+ if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
- self.showLoadingProgress = false
- willNavigate()
- ChatModel.shared.chatId = chatId
+ self.showLoadingProgress = nil
}
}
}
}
+
+ func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
+ navigationTimeoutTask?.cancel()
+ loadChatTask?.cancel()
+ loadChatTask = Task {
+ // try? await Task.sleep(nanoseconds: 1000_000000)
+ await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
+ if !Task.isCancelled {
+ await MainActor.run {
+ if openAroundItemId == nil {
+ ChatModel.shared.chatId = chatId
+ }
+ }
+ }
+ }
+ }
+}
+
+class ChatTagsModel: ObservableObject {
+ static let shared = ChatTagsModel()
+
+ @Published var userTags: [ChatTag] = []
+ @Published var activeFilter: ActiveFilter? = nil
+ @Published var presetTags: [PresetTag:Int] = [:]
+ @Published var unreadTags: [Int64:Int] = [:]
+
+ func updateChatTags(_ chats: [Chat]) {
+ let tm = ChatTagsModel.shared
+ var newPresetTags: [PresetTag:Int] = [:]
+ var newUnreadTags: [Int64:Int] = [:]
+ for chat in chats {
+ for tag in PresetTag.allCases {
+ if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) {
+ newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
+ }
+ }
+ if chat.unreadTag, let tags = chat.chatInfo.chatTags {
+ for tag in tags {
+ newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1
+ }
+ }
+ }
+ presetTags = newPresetTags
+ unreadTags = newUnreadTags
+ clearActiveChatFilterIfNeeded()
+ }
+
+ func updateChatFavorite(favorite: Bool, wasFavorite: Bool) {
+ let count = presetTags[.favorites]
+ if favorite && !wasFavorite {
+ presetTags[.favorites] = (count ?? 0) + 1
+ } else if !favorite && wasFavorite, let count {
+ presetTags[.favorites] = max(0, count - 1)
+ clearActiveChatFilterIfNeeded()
+ }
+ }
+
+ func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
+ for tag in PresetTag.allCases {
+ if presetTagMatchesChat(tag, chatInfo, chatStats) {
+ presetTags[tag] = (presetTags[tag] ?? 0) + 1
+ }
+ }
+ }
+
+ func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
+ for tag in PresetTag.allCases {
+ if presetTagMatchesChat(tag, chatInfo, chatStats) {
+ if let count = presetTags[tag] {
+ if count > 1 {
+ presetTags[tag] = count - 1
+ } else {
+ presetTags.removeValue(forKey: tag)
+ }
+ }
+ }
+ }
+ clearActiveChatFilterIfNeeded()
+ }
+
+ func markChatTagRead(_ chat: Chat) -> Void {
+ if chat.unreadTag, let tags = chat.chatInfo.chatTags {
+ decTagsReadCount(tags)
+ }
+ }
+
+ func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void {
+ guard let tags = chat.chatInfo.chatTags else { return }
+ let nowUnread = chat.unreadTag
+ if nowUnread && !wasUnread {
+ for tag in tags {
+ unreadTags[tag] = (unreadTags[tag] ?? 0) + 1
+ }
+ } else if !nowUnread && wasUnread {
+ decTagsReadCount(tags)
+ }
+ }
+
+ func decTagsReadCount(_ tags: [Int64]) -> Void {
+ for tag in tags {
+ if let count = unreadTags[tag] {
+ unreadTags[tag] = max(0, count - 1)
+ }
+ }
+ }
+
+ func changeGroupReportsTag(_ by: Int = 0) {
+ if by == 0 { return }
+ presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by)
+ clearActiveChatFilterIfNeeded()
+ }
+
+ func clearActiveChatFilterIfNeeded() {
+ let clear = switch activeFilter {
+ case let .presetTag(tag): (presetTags[tag] ?? 0) == 0
+ case let .userTag(tag): !userTags.contains(tag)
+ case .unread, nil: false
+ }
+ if clear { activeFilter = nil }
+ }
}
class NetworkModel: ObservableObject {
@@ -123,6 +253,14 @@ class NetworkModel: ObservableObject {
}
}
+/// ChatItemWithMenu can depend on previous or next item for it's appearance
+/// This dummy model is used to force an update of all chat items,
+/// when they might have changed appearance.
+class ChatItemDummyModel: ObservableObject {
+ static let shared = ChatItemDummyModel()
+ func sendUpdate() { objectWillChange.send() }
+}
+
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -139,6 +277,7 @@ final class ChatModel: ObservableObject {
@Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false
+ @Published var notificationResponse: UNNotificationResponse?
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest?
@@ -147,6 +286,7 @@ final class ChatModel: ObservableObject {
@Published var deletedChats: Set = []
// current chat
@Published var chatId: String?
+ @Published var openAroundItemId: ChatItem.ID? = nil
var chatItemStatuses: Dictionary = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@@ -161,6 +301,7 @@ final class ChatModel: ObservableObject {
@Published var deviceToken: DeviceToken?
@Published var savedToken: DeviceToken?
@Published var tokenRegistered = false
+ @Published var reRegisterTknStatus: NtfTknStatus? = nil
@Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off
@Published var notificationServer: String?
@@ -183,8 +324,9 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
@Published var draftChatId: String?
- @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
+ // usage conditions
+ @Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary Void> = [:]
@@ -334,6 +476,7 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
+ ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
}
}
@@ -391,7 +534,7 @@ final class ChatModel: ObservableObject {
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus {
- unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
+ unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
@@ -428,19 +571,18 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
- withConditionalAnimation {
- _updateChatItem(at: i, with: cItem)
- }
+ _updateChatItem(at: i, with: cItem)
+ ChatItemDummyModel.shared.sendUpdate()
return false
} else {
- withConditionalAnimation(itemAnimation()) {
- var ci = cItem
- if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
- ci.meta.itemStatus = status
- }
- im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
- im.itemAdded = true
+ var ci = cItem
+ if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
+ ci.meta.itemStatus = status
}
+ im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
+ im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
+ im.itemAdded = true
+ ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -473,7 +615,7 @@ final class ChatModel: ObservableObject {
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
- unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
+ unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
}
// update previews
if let chat = getChat(cInfo.id) {
@@ -484,14 +626,54 @@ final class ChatModel: ObservableObject {
// remove from current chat
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
- _ = withAnimation {
- im.reversedChatItems.remove(at: i)
+ withAnimation {
+ let item = im.reversedChatItems.remove(at: i)
+ im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
+ func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
+ // this should not happen, only another member can "remove" user, user can only "leave" (another event).
+ if byMember.groupMemberId == groupInfo.membership.groupMemberId {
+ logger.debug("exiting removeMemberItems")
+ return
+ }
+ if chatId == groupInfo.id {
+ for i in 0.. 0,
+ let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
+ chat.chatItems = [updatedItem]
+ }
+
+ func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
+ let newContent: CIContent
+ if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
+ newContent = .sndModerated
+ } else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
+ newContent = .rcvModerated
+ } else {
+ return nil
+ }
+ var updatedItem = item
+ updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
+ if groupInfo.fullGroupPreferences.fullDelete.on {
+ updatedItem.content = newContent
+ }
+ if item.isActiveReport {
+ decreaseGroupReportsCounter(groupInfo.id)
+ }
+ return updatedItem
+ }
+ }
+
func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
@@ -521,7 +703,7 @@ final class ChatModel: ObservableObject {
}
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
- guard var current = currentUser else { return }
+ guard var current = currentUser, current.uiThemes != uiThemes else { return }
current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i {
@@ -534,6 +716,7 @@ final class ChatModel: ObservableObject {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
im.reversedChatItems.insert(cItem, at: 0)
+ im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
im.itemAdded = true
}
return cItem
@@ -553,63 +736,37 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true
}
- func markChatItemsRead(_ cInfo: ChatInfo) {
+ func markAllChatItemsRead(_ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
- self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
+ self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
+ ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
}
// update current chat
if chatId == cInfo.id {
- markCurrentChatRead()
- }
- }
-
- private func markCurrentChatRead(fromIndex i: Int = 0) {
- var j = i
- while j < im.reversedChatItems.count {
- markChatItemRead_(j)
- j += 1
- }
- }
-
- func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
- if let cItem = aboveItem {
- if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
- markCurrentChatRead(fromIndex: i)
- _updateChat(cInfo.id) { chat in
- var unreadBelow = 0
- var j = i - 1
- while j >= 0 {
- if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
- unreadBelow += 1
- }
- j -= 1
- }
- // update preview
- let markedCount = chat.chatStats.unreadCount - unreadBelow
- if markedCount > 0 {
- chat.chatStats.unreadCount -= markedCount
- self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
- }
- }
+ var i = 0
+ while i < im.reversedChatItems.count {
+ markChatItemRead_(i)
+ i += 1
}
- } else {
- markChatItemsRead(cInfo)
+ im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
}
}
-
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
_updateChat(cInfo.id) { chat in
+ let wasUnread = chat.unreadTag
chat.chatStats.unreadChat = unreadChat
+ ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
}
}
func clearChat(_ cInfo: ChatInfo) {
// clear preview
if let chat = getChat(cInfo.id) {
- self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
+ self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
chat.chatItems = []
+ ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
chat.chatInfo = cInfo
}
@@ -617,22 +774,27 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
+ im.chatState.clear()
}
}
- func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
- if chatId == cInfo.id,
- let itemIndex = getChatItemIndex(cItem),
- im.reversedChatItems[itemIndex].isRcvNew {
- await MainActor.run {
- withTransaction(Transaction()) {
- // update current chat
- markChatItemRead_(itemIndex)
- // update preview
- unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
+ func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
+ if self.chatId == cInfo.id {
+ var unreadItemIds: Set = []
+ var i = 0
+ var ids = Set(itemIds)
+ while i < im.reversedChatItems.count && !ids.isEmpty {
+ let item = im.reversedChatItems[i]
+ if ids.contains(item.id) && item.isRcvNew {
+ markChatItemRead_(i)
+ unreadItemIds.insert(item.id)
+ ids.remove(item.id)
}
+ i += 1
}
+ im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
}
+ self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
private let unreadCollector = UnreadCollector()
@@ -640,16 +802,16 @@ final class ChatModel: ObservableObject {
class UnreadCollector {
private let subject = PassthroughSubject()
private var bag = Set()
- private var unreadCounts: [ChatId: Int] = [:]
+ private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
- for (chatId, count) in self.unreadCounts {
- if let i = m.getChatIndex(chatId) {
- m.changeUnreadCounter(i, by: count)
+ for (chatId, (unread, mentions)) in self.unreadCounts {
+ if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) {
+ m.changeUnreadCounter(i, by: unread, unreadMentions: mentions)
}
}
self.unreadCounts = [:]
@@ -657,10 +819,9 @@ final class ChatModel: ObservableObject {
.store(in: &bag)
}
- func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
- DispatchQueue.main.async {
- self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
- }
+ func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
+ let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
+ self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
subject.send()
}
}
@@ -738,8 +899,12 @@ final class ChatModel: ObservableObject {
}
}
- func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
- chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
+ func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) {
+ let wasUnread = chats[chatIndex].unreadTag
+ let stats = chats[chatIndex].chatStats
+ chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count
+ chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions
+ ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
changeUnreadCounter(user: currentUser!, by: count)
}
@@ -747,6 +912,13 @@ final class ChatModel: ObservableObject {
changeUnreadCounter(user: user, by: 1)
}
+ func decreaseUnreadCounter(user: any UserLike, chat: Chat) {
+ let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions
+ ? chat.chatStats.unreadMentions
+ : chat.chatStats.unreadCount
+ decreaseUnreadCounter(user: user, by: by)
+ }
+
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by)
}
@@ -759,8 +931,41 @@ final class ChatModel: ObservableObject {
}
func totalUnreadCountForAllUsers() -> Int {
- chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
- users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
+ var unread: Int = 0
+ for chat in chats {
+ switch chat.chatInfo.chatSettings?.enableNtfs {
+ case .all: unread += chat.chatStats.unreadCount
+ case .mentions: unread += chat.chatStats.unreadMentions
+ default: ()
+ }
+ }
+ for u in users {
+ if !u.user.activeUser {
+ unread += u.unreadCount
+ }
+ }
+ return unread
+ }
+
+ func increaseGroupReportsCounter(_ chatId: ChatId) {
+ changeGroupReportsCounter(chatId, 1)
+ }
+
+ func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
+ changeGroupReportsCounter(chatId, -by)
+ }
+
+ private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
+ if by == 0 { return }
+
+ if let i = getChatIndex(chatId) {
+ let chat = chats[i]
+ let wasReportsCount = chat.chatStats.reportsCount
+ chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by)
+ let nowReportsCount = chat.chatStats.reportsCount
+ let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0
+ ChatTagsModel.shared.changeGroupReportsTag(by)
+ }
}
// this function analyses "connected" events and assumes that each member will be there only once
@@ -808,12 +1013,17 @@ final class ChatModel: ObservableObject {
// returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) {
+ let items = im.reversedChatItems
var prevMember: GroupMember? = nil
var memberIds: Set = []
for i in range {
- if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
- if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
- memberIds.insert(m.groupMemberId)
+ if i < items.count {
+ if case let .groupRcv(m) = items[i].chatDir {
+ if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
+ memberIds.insert(m.groupMemberId)
+ }
+ } else {
+ logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
}
}
return (prevMember, memberIds.count)
@@ -832,7 +1042,7 @@ final class ChatModel: ObservableObject {
}
func dismissConnReqView(_ id: String) {
- if id == showingInvitation?.connId {
+ if id == showingInvitation?.pcc.id {
markShowingInvitationUsed()
dismissAllSheets()
}
@@ -844,7 +1054,11 @@ final class ChatModel: ObservableObject {
func removeChat(_ id: String) {
withAnimation {
- chats.removeAll(where: { $0.id == id })
+ if let i = getChatIndex(id) {
+ let removed = chats.remove(at: i)
+ ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
+ removeWallpaperFilesFromChat(removed)
+ }
}
}
@@ -883,38 +1097,26 @@ final class ChatModel: ObservableObject {
}
}
- func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts {
- var i = 0
- var totalBelow = 0
- var unreadBelow = 0
- while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
- totalBelow += 1
- if im.reversedChatItems[i].isRcvNew {
- unreadBelow += 1
- }
- i += 1
+ func removeWallpaperFilesFromChat(_ chat: Chat) {
+ if case let .direct(contact) = chat.chatInfo {
+ removeWallpaperFilesFromTheme(contact.uiThemes)
+ } else if case let .group(groupInfo) = chat.chatInfo {
+ removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
- return UnreadChatItemCounts(
- // TODO these thresholds account for the fact that items are still "visible" while
- // covered by compose area, they should be replaced with the actual height in pixels below the screen.
- isNearBottom: totalBelow < 15,
- isReallyNearBottom: totalBelow < 2,
- unreadBelow: unreadBelow
- )
}
- func topItemInView(itemsInView: Set) -> ChatItem? {
- let maxIx = im.reversedChatItems.count - 1
- var i = 0
- let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
- while i < maxIx && !inView(i) { i += 1 }
- while i < maxIx && inView(i) { i += 1 }
- return im.reversedChatItems[min(i - 1, maxIx)]
+ func removeWallpaperFilesFromAllChats(_ user: User) {
+ // Currently, only removing everything from currently active user is supported. Inactive users are TODO
+ if user.userId == currentUser?.userId {
+ chats.forEach {
+ removeWallpaperFilesFromChat($0)
+ }
+ }
}
}
struct ShowingInvitation {
- var connId: String
+ var pcc: PendingContactConnection
var connChatUsed: Bool
}
@@ -923,12 +1125,6 @@ struct NTFContactRequest {
var chatId: String
}
-struct UnreadChatItemCounts: Equatable {
- var isNearBottom: Bool
- var isReallyNearBottom: Bool
- var unreadBelow: Int
-}
-
final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@@ -956,27 +1152,14 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
)
}
- var userCanSend: Bool {
- switch chatInfo {
- case .direct: return true
- case let .group(groupInfo):
- let m = groupInfo.membership
- return m.memberActive && m.memberRole >= .member
- case .local:
- return true
- default: return false
+ var unreadTag: Bool {
+ switch chatInfo.chatSettings?.enableNtfs {
+ case .all: chatStats.unreadChat || chatStats.unreadCount > 0
+ case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0
+ default: chatStats.unreadChat
}
}
-
- var userIsObserver: Bool {
- switch chatInfo {
- case let .group(groupInfo):
- let m = groupInfo.membership
- return m.memberActive && m.memberRole == .observer
- default: return false
- }
- }
-
+
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index 95063845f1..da55bd90d0 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -26,20 +26,37 @@ enum NtfCallAction {
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager()
+ public var navigatingToChat = false
private var granted = false
private var prevNtfTime: Dictionary = [:]
+ override init() {
+ super.init()
+ UNUserNotificationCenter.current().delegate = self
+ }
+
// Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive")
- let content = response.notification.request.content
+ if appStateGroupDefault.get() == .active {
+ processNotificationResponse(response)
+ } else {
+ logger.debug("NtfManager.userNotificationCenter: remember response in model")
+ ChatModel.shared.notificationResponse = response
+ }
+ handler()
+ }
+
+ func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared
- let action = response.actionIdentifier
- logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
+ let content = ntfResponse.notification.request.content
+ let action = ntfResponse.actionIdentifier
+ logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId {
+ logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@@ -58,10 +75,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
} else {
if let chatId = content.targetContentIdentifier {
- ItemsModel.shared.loadOpenChat(chatId)
+ self.navigatingToChat = true
+ ItemsModel.shared.loadOpenChat(chatId) {
+ self.navigatingToChat = false
+ }
}
}
- handler()
}
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -76,7 +95,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil
}
-
// Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -185,6 +203,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
+ ),
+ UNNotificationCategory(
+ identifier: ntfCategoryManyEvents,
+ actions: [],
+ intentIdentifiers: [],
+ hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
)
])
}
@@ -210,29 +234,28 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
}
}
- center.delegate = self
}
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
- addNotification(createContactRequestNtf(user, contactRequest))
+ addNotification(createContactRequestNtf(user, contactRequest, 0))
}
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
- addNotification(createContactConnectedNtf(user, contact))
+ addNotification(createContactConnectedNtf(user, contact, 0))
}
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
- if cInfo.ntfsEnabled {
- addNotification(createMessageReceivedNtf(user, cInfo, cItem))
+ if cInfo.ntfsEnabled(chatItem: cItem) {
+ addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
- addNotification(createCallInvitationNtf(invitation))
+ addNotification(createCallInvitationNtf(invitation, 0))
}
func setNtfBadgeCount(_ count: Int) {
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index ebc58c6a05..d92411decd 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -11,46 +11,42 @@ import UIKit
import Dispatch
import BackgroundTasks
import SwiftUI
-import SimpleXChat
+@preconcurrency import SimpleXChat
private var chatController: chat_ctrl?
-// currentChatVersion in core
-public let CURRENT_CHAT_VERSION: Int = 2
-
-// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
-public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
-
private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock")
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
- case resp(Date, ChatResponse)
+ case res(Date, ChatAPIResult)
+ case err(Date, ChatError)
+ case bad(Date, String, Data?)
var id: Date {
- get {
- switch self {
- case let .cmd(id, _): return id
- case let .resp(id, _): return id
- }
+ switch self {
+ case let .cmd(d, _): d
+ case let .res(d, _): d
+ case let .err(d, _): d
+ case let .bad(d, _, _): d
}
}
var label: String {
- get {
- switch self {
- case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))"
- case let .resp(_, resp): return "< \(resp.responseType)"
- }
+ switch self {
+ case let .cmd(_, cmd): "> \(cmd.cmdString.prefix(30))"
+ case let .res(_, res): "< \(res.responseType)"
+ case let .err(_, err): "< error \(err.errorType)"
+ case let .bad(_, type, _): "< * \(type)"
}
}
var details: String {
- get {
- switch self {
- case let .cmd(_, cmd): return cmd.cmdString
- case let .resp(_, resp): return resp.details
- }
+ switch self {
+ case let .cmd(_, cmd): cmd.cmdString
+ case let .res(_, res): res.details
+ case let .err(_, err): String(describing: err)
+ case let .bad(_, _, json): dataToString(json)
}
}
}
@@ -92,18 +88,24 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r
}
-func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
+@inline(__always)
+func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R {
+ let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
+ return try apiResult(res)
+}
+
+func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) -> APIResult {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)")
}
let start = Date.now
- let resp = bgTask
+ let resp: APIResult = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
: sendSimpleXCmd(cmd, ctrl)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
- if case let .response(_, json) = resp {
- logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
+ if case let .invalid(_, json) = resp {
+ logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))")
}
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
@@ -112,35 +114,51 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp
}
-func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
+@inline(__always)
+func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R {
+ let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
+ return try apiResult(res)
+}
+
+@inline(__always)
+func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async -> APIResult {
await withCheckedContinuation { cont in
- cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
+ cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log))
}
}
-func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? {
+@inline(__always)
+func apiResult(_ res: APIResult) throws -> R {
+ switch res {
+ case let .result(r): return r
+ case let .error(e): throw e
+ case let .invalid(type, _): throw ChatError.unexpectedResult(type: type)
+ }
+}
+
+func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? {
await withCheckedContinuation { cont in
- _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
- let resp = recvSimpleXMsg(ctrl)
- cont.resume(returning: resp)
- return resp
+ _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in
+ let evt: APIResult? = recvSimpleXMsg(ctrl)
+ cont.resume(returning: evt)
+ return evt
}
}
}
func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
- let r = chatSendCmdSync(.showActiveUser, ctrl)
+ let r: APIResult = chatApiSendCmdSync(.showActiveUser, ctrl: ctrl)
switch r {
- case let .activeUser(user): return user
- case .chatCmdError(_, .error(.noActiveUser)): return nil
- default: throw r
+ case let .result(.activeUser(user)): return user
+ case .error(.error(.noActiveUser)): return nil
+ default: throw r.unexpected
}
}
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
- let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
+ let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl)
if case let .activeUser(user) = r { return user }
- throw r
+ throw r.unexpected
}
func listUsers() throws -> [UserInfo] {
@@ -151,41 +169,35 @@ func listUsersAsync() async throws -> [UserInfo] {
return try listUsersResponse(await chatSendCmd(.listUsers))
}
-private func listUsersResponse(_ r: ChatResponse) throws -> [UserInfo] {
+private func listUsersResponse(_ r: ChatResponse0) throws -> [UserInfo] {
if case let .usersList(users) = r {
return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending }
}
- throw r
+ throw r.unexpected
}
func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User {
- let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
if case let .activeUser(user) = r { return user }
- throw r
+ throw r.unexpected
}
func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User {
- let r = await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
+ let r: ChatResponse0 = try await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
if case let .activeUser(user) = r { return user }
- throw r
+ throw r.unexpected
}
func apiSetAllContactReceipts(enable: Bool) async throws {
- let r = await chatSendCmd(.setAllContactReceipts(enable: enable))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.setAllContactReceipts(enable: enable))
}
func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
- let r = await chatSendCmd(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
}
func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
- let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
}
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
@@ -205,90 +217,88 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User {
}
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
- let r = await chatSendCmd(cmd)
+ let r: ChatResponse1 = try await chatSendCmd(cmd)
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
- throw r
+ throw r.unexpected
}
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws {
- let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
- let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
+ let r: ChatResponse0 = try chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl: ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
- default: throw r
+ default: throw r.unexpected
}
}
func apiCheckChatRunning() throws -> Bool {
- let r = chatSendCmdSync(.checkChatRunning)
+ let r: ChatResponse0 = try chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
- default: throw r
+ default: throw r.unexpected
}
}
func apiStopChat() async throws {
- let r = await chatSendCmd(.apiStopChat)
+ let r: ChatResponse0 = try await chatSendCmd(.apiStopChat)
switch r {
case .chatStopped: return
- default: throw r
+ default: throw r.unexpected
}
}
func apiActivateChat() {
chatReopenStore()
- let r = chatSendCmdSync(.apiActivateChat(restoreChat: true))
- if case .cmdOk = r { return }
- logger.error("apiActivateChat error: \(String(describing: r))")
+ do {
+ try sendCommandOkRespSync(.apiActivateChat(restoreChat: true))
+ } catch {
+ logger.error("apiActivateChat error: \(responseError(error))")
+ }
}
func apiSuspendChat(timeoutMicroseconds: Int) {
- let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
- if case .cmdOk = r { return }
- logger.error("apiSuspendChat error: \(String(describing: r))")
+ do {
+ try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
+ } catch {
+ logger.error("apiSuspendChat error: \(responseError(error))")
+ }
}
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
- let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
- if case .cmdOk = r { return }
- throw r
+ try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable))
}
func apiSaveAppSettings(settings: AppSettings) throws {
- let r = chatSendCmdSync(.apiSaveSettings(settings: settings))
- if case .cmdOk = r { return }
- throw r
+ try sendCommandOkRespSync(.apiSaveSettings(settings: settings))
}
func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
- let r = chatSendCmdSync(.apiGetSettings(settings: settings))
+ let r: ChatResponse2 = try chatSendCmdSync(.apiGetSettings(settings: settings))
if case let .appSettings(settings) = r { return settings }
- throw r
+ throw r.unexpected
}
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
- let r = await chatSendCmd(.apiExportArchive(config: config))
+ let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config))
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
- throw r
+ throw r.unexpected
}
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
- let r = await chatSendCmd(.apiImportArchive(config: config))
+ let r: ChatResponse2 = try await chatSendCmd(.apiImportArchive(config: config))
if case let .archiveImported(archiveErrors) = r { return archiveErrors }
- throw r
+ throw r.unexpected
}
func apiDeleteStorage() async throws {
@@ -299,8 +309,8 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey)))
}
-func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws {
- try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl)
+func testStorageEncryption(key: String, ctrl: chat_ctrl? = nil) async throws {
+ try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl: ctrl)
}
func apiGetChats() throws -> [ChatData] {
@@ -313,63 +323,103 @@ func apiGetChatsAsync() async throws -> [ChatData] {
return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId)))
}
-private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
+private func apiChatsResponse(_ r: ChatResponse0) throws -> [ChatData] {
if case let .apiChats(_, chats) = r { return chats }
- throw r
+ throw r.unexpected
+}
+
+func apiGetChatTags() throws -> [ChatTag] {
+ let userId = try currentUserId("apiGetChatTags")
+ let r: ChatResponse0 = try chatSendCmdSync(.apiGetChatTags(userId: userId))
+ if case let .chatTags(_, tags) = r { return tags }
+ throw r.unexpected
+}
+
+func apiGetChatTagsAsync() async throws -> [ChatTag] {
+ let userId = try currentUserId("apiGetChatTags")
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChatTags(userId: userId))
+ if case let .chatTags(_, tags) = r { return tags }
+ throw r.unexpected
}
let loadItemsPerPage = 50
-func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
- let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
- if case let .apiChat(_, chat) = r { return Chat.init(chat) }
- throw r
-}
-
-func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
- let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
- if case let .apiChat(_, chat) = r { return chat.chatItems }
- throw r
+func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
+ if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
+ throw r.unexpected
}
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
- do {
- let cInfo = chat.chatInfo
- let m = ChatModel.shared
- let im = ItemsModel.shared
+ await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
+}
+
+func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
+ let m = ChatModel.shared
+ let im = ItemsModel.shared
+ await MainActor.run {
m.chatItemStatuses = [:]
if clearItems {
- await MainActor.run { im.reversedChatItems = [] }
+ im.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
}
- let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
- await MainActor.run {
- im.reversedChatItems = chat.chatItems.reversed()
- m.updateChatInfo(chat.chatInfo)
- }
- } catch let error {
- logger.error("loadChat error: \(responseError(error))")
}
+ await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 })
}
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
- let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
- throw r
+ throw r.unexpected
}
-func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
- let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
+func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
+ let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
+ if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
+ throw r.unexpected
+}
+
+func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
-func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
- let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
+func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] {
+ let r: ChatResponse0 = try await chatSendCmd(.apiCreateChatTag(tag: tag))
+ if case let .chatTags(_, userTags) = r {
+ return userTags
+ }
+ throw r.unexpected
+}
+
+func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds))
+ if case let .tagsUpdated(_, userTags, chatTags) = r {
+ return (userTags, chatTags)
+ }
+ throw r.unexpected
+}
+
+func apiDeleteChatTag(tagId: Int64) async throws {
+ try await sendCommandOkResp(.apiDeleteChatTag(tagId: tagId))
+}
+
+func apiUpdateChatTag(tagId: Int64, tag: ChatTagData) async throws {
+ try await sendCommandOkResp(.apiUpdateChatTag(tagId: tagId, tagData: tag))
+}
+
+func apiReorderChatTags(tagIds: [Int64]) async throws {
+ try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
+}
+
+func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
-private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
+private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
let chatModel = ChatModel.shared
- let r: ChatResponse
+ let r: APIResult
if toChatType == .direct {
var cItem: ChatItem? = nil
let endTask = beginBGTask({
@@ -379,37 +429,52 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
}
}
})
- r = await chatSendCmd(cmd, bgTask: false)
- if case let .newChatItem(_, aChatItem) = r {
- cItem = aChatItem.chatItem
- chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
- return cItem
+ r = await chatApiSendCmd(cmd, bgTask: false)
+ if case let .result(.newChatItems(_, aChatItems)) = r {
+ let cItems = aChatItems.map { $0.chatItem }
+ if let cItemLast = cItems.last {
+ cItem = cItemLast
+ chatModel.messageDelivery[cItemLast.id] = endTask
+ }
+ return cItems
}
if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert)
} else {
- sendMessageErrorAlert(r)
+ sendMessageErrorAlert(r.unexpected)
}
endTask()
return nil
} else {
- r = await chatSendCmd(cmd, bgDelay: msgDelay)
- if case let .newChatItem(_, aChatItem) = r {
- return aChatItem.chatItem
+ r = await chatApiSendCmd(cmd, bgDelay: msgDelay)
+ if case let .result(.newChatItems(_, aChatItems)) = r {
+ return aChatItems.map { $0.chatItem }
}
- sendMessageErrorAlert(r)
+ sendMessageErrorAlert(r.unexpected)
return nil
}
}
-func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
- let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
- if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
- createChatItemErrorAlert(r)
+func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let r: APIResult = await chatApiSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
+ if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } }
+ createChatItemsErrorAlert(r.unexpected)
return nil
}
-private func sendMessageErrorAlert(_ r: ChatResponse) {
+func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? {
+ let r: APIResult = await chatApiSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText))
+ if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } }
+
+ logger.error("apiReportMessage error: \(String(describing: r))")
+ AlertManager.shared.showAlertMsg(
+ title: "Error creating report",
+ message: "Error: \(responseError(r.unexpected))"
+ )
+ return nil
+}
+
+private func sendMessageErrorAlert(_ r: ChatError) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
@@ -417,43 +482,65 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
)
}
-private func createChatItemErrorAlert(_ r: ChatResponse) {
- logger.error("apiCreateChatItem error: \(String(describing: r))")
+private func createChatItemsErrorAlert(_ r: ChatError) {
+ logger.error("apiCreateChatItems error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(responseError(r))"
)
}
-func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
- let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
- if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem }
- throw r
+func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
+ let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
+ switch r {
+ case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem
+ case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem
+ default: throw r.unexpected
+ }
}
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
- let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
+ let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
- throw r
+ throw r.unexpected
+}
+
+func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
+ let userId = try currentUserId("apiGetReactionMemebers")
+ let r: ChatResponse1 = try await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
+ if case let .reactionMembers(_, memberReactions) = r { return memberReactions }
+ throw r.unexpected
}
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
- let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
- throw r
+ throw r.unexpected
}
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
- let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
- throw r
+ throw r.unexpected
+}
+
+func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse1 {
+ let r: ChatResponse1 = try await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay)
+ if case .groupChatItemsDeleted = r { return r }
+ throw r.unexpected
+}
+
+func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
+ if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions }
+ throw r.unexpected
}
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
- let r = chatSendCmdSync(.apiGetNtfToken)
+ let r: APIResult = chatApiSendCmdSync(.apiGetNtfToken)
switch r {
- case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
- case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
+ case let .result(.ntfToken(token, status, ntfMode, ntfServer)): return (token, status, ntfMode, ntfServer)
+ case .error(.errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil)
@@ -461,9 +548,9 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String
}
func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus {
- let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode))
+ let r: ChatResponse2 = try await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode))
if case let .ntfTokenStatus(status) = r { return status }
- throw r
+ throw r.unexpected
}
func registerToken(token: DeviceToken) {
@@ -475,7 +562,12 @@ func registerToken(token: DeviceToken) {
Task {
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
- await MainActor.run { m.tokenStatus = status }
+ await MainActor.run {
+ m.tokenStatus = status
+ if !status.workingToken {
+ m.reRegisterTknStatus = status
+ }
+ }
} catch let error {
logger.error("registerToken apiRegisterToken error: \(responseError(error))")
}
@@ -483,36 +575,129 @@ func registerToken(token: DeviceToken) {
}
}
+func tokenStatusInfo(_ status: NtfTknStatus, register: Bool) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Token status: %@.", comment: "token status"), status.text)
+ + "\n" + status.info(register: register)
+}
+
+func reRegisterToken(token: DeviceToken) {
+ let m = ChatModel.shared
+ let mode = m.notificationMode
+ logger.debug("reRegisterToken \(mode.rawValue)")
+ Task {
+ do {
+ let status = try await apiRegisterToken(token: token, notificationMode: mode)
+ await MainActor.run {
+ m.tokenStatus = status
+ showAlert(
+ status.workingToken
+ ? NSLocalizedString("Notifications status", comment: "alert title")
+ : NSLocalizedString("Notifications error", comment: "alert title"),
+ message: tokenStatusInfo(status, register: false)
+ )
+ }
+ } catch let error {
+ logger.error("reRegisterToken apiRegisterToken error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error registering for notifications", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code))
}
+func apiCheckToken(token: DeviceToken) async throws -> NtfTknStatus {
+ let r: ChatResponse2 = try await chatSendCmd(.apiCheckToken(token: token))
+ if case let .ntfTokenStatus(status) = r { return status }
+ throw r.unexpected
+}
+
func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
-func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
- let userId = try currentUserId("getUserProtoServers")
- let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
- if case let .userProtoServers(_, servers) = r { return servers }
- throw r
-}
-
-func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
- let userId = try currentUserId("setUserProtoServers")
- try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
-}
-
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
- let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
+ let r: ChatResponse0 = try await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
if case let .serverTestResult(_, _, testFailure) = r {
if let t = testFailure {
return .failure(t)
}
return .success(())
}
- throw r
+ throw r.unexpected
+}
+
+func getServerOperators() async throws -> ServerOperatorConditions {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators)
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("getServerOperators error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func getServerOperatorsSync() throws -> ServerOperatorConditions {
+ let r: ChatResponse0 = try chatSendCmdSync(.apiGetServerOperators)
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("getServerOperators error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
+ let r: ChatResponse0 = try await chatSendCmd(.apiSetServerOperators(operators: operators))
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("setServerOperators error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func getUserServers() async throws -> [UserOperatorServers] {
+ let userId = try currentUserId("getUserServers")
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetUserServers(userId: userId))
+ if case let .userServers(_, userServers) = r { return userServers }
+ logger.error("getUserServers error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func setUserServers(userServers: [UserOperatorServers]) async throws {
+ let userId = try currentUserId("setUserServers")
+ let r: ChatResponse2 = try await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
+ if case .cmdOk = r { return }
+ logger.error("setUserServers error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
+ let userId = try currentUserId("validateServers")
+ let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
+ if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
+ logger.error("validateServers error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetUsageConditions)
+ if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
+ logger.error("getUsageConditions error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func setConditionsNotified(conditionsId: Int64) async throws {
+ let r: ChatResponse2 = try await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
+ if case .cmdOk = r { return }
+ logger.error("setConditionsNotified error: \(String(describing: r))")
+ throw r.unexpected
+}
+
+func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
+ let r: ChatResponse0 = try await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("acceptConditions error: \(String(describing: r))")
+ throw r.unexpected
}
func getChatItemTTL() throws -> ChatItemTTL {
@@ -525,9 +710,15 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL {
return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId)))
}
-private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
- if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
- throw r
+private func chatItemTTLResponse(_ r: ChatResponse0) throws -> ChatItemTTL {
+ if case let .chatItemTTL(_, chatItemTTL) = r {
+ if let ttl = chatItemTTL {
+ return ChatItemTTL(ttl)
+ } else {
+ throw RuntimeError("chatItemTTLResponse: invalid ttl")
+ }
+ }
+ throw r.unexpected
}
func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
@@ -535,22 +726,27 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
}
+func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws {
+ let userId = try currentUserId("setChatItemTTL")
+ try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value))
+}
+
func getNetworkConfig() async throws -> NetCfg? {
- let r = await chatSendCmd(.apiGetNetworkConfig)
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetNetworkConfig)
if case let .networkConfig(cfg) = r { return cfg }
- throw r
+ throw r.unexpected
}
func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws {
- let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func reconnectAllServers() async throws {
@@ -571,118 +767,135 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett
}
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
- let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
+ let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
- throw r
+ throw r.unexpected
}
-func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
- let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
+func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
+ let r: ChatResponse0 = try chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
- throw r
+ throw r.unexpected
}
-func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
- let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
- if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r
+func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
+ throw r.unexpected
}
-func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
- let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
+func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r
+ throw r.unexpected
+}
+
+func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
+ throw r.unexpected
}
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
- let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchContact(contactId: contactId))
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
- throw r
+ throw r.unexpected
}
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
- let r = chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats }
- throw r
+ throw r.unexpected
}
func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats {
- let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId))
if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats }
- throw r
+ throw r.unexpected
}
func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
- let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats }
- throw r
+ throw r.unexpected
}
func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats {
- let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force))
if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats }
- throw r
+ throw r.unexpected
}
func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) {
- let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force))
if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) }
- throw r
+ throw r.unexpected
}
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
- let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetContactCode(contactId: contactId))
if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) }
- throw r
+ throw r.unexpected
}
-func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
- let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
+func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) }
- throw r
+ throw r.unexpected
}
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
- let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
- if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) }
+ let r: APIResult = chatApiSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
+ if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) }
logger.error("apiVerifyContact error: \(String(describing: r))")
return nil
}
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
- let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
- if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) }
+ let r: APIResult = chatApiSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
+ if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) }
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
return nil
}
-func apiAddContact(incognito: Bool) async -> ((String, PendingContactConnection)?, Alert?) {
+func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return (nil, nil)
}
- let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
- if case let .invitation(_, connReqInvitation, connection) = r { return ((connReqInvitation, connection), nil) }
+ let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
+ let r: APIResult = await chatApiSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false)
+ if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) }
let alert = connectionErrorAlert(r)
return (nil, alert)
}
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
- let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection }
- throw r
+ throw r.unexpected
}
-func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
- let userId = try currentUserId("apiConnectPlan")
- let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
- if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan }
- logger.error("apiConnectPlan error: \(responseError(r))")
- throw r
+func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
+
+ if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
+ throw r.unexpected
}
-func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
- let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
+func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
+ guard let userId = ChatModel.shared.currentUser?.userId else {
+ logger.error("apiConnectPlan: no current user")
+ return (nil, nil)
+ }
+ let r: APIResult = await chatApiSendCmd(.apiConnectPlan(userId: userId, connLink: connLink))
+ if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) }
+ let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r)
+ return (nil, alert)
+}
+
+func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? {
+ let (r, alert) = await apiConnect_(incognito: incognito, connLink: connLink)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
@@ -691,48 +904,74 @@ func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, Pending
}
}
-func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
+func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
}
- let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
+ let r: APIResult = await chatApiSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
let m = ChatModel.shared
switch r {
- case let .sentConfirmation(_, connection):
+ case let .result(.sentConfirmation(_, connection)):
return ((.invitation, connection), nil)
- case let .sentInvitation(_, connection):
+ case let .result(.sentInvitation(_, connection)):
return ((.contact, connection), nil)
- case let .contactAlreadyExists(_, contact):
+ case let .result(.contactAlreadyExists(_, contact)):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
- case .chatCmdError(_, .error(.invalidConnReq)):
- let alert = mkAlert(
+ default: ()
+ }
+ let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r)
+ return (nil, alert)
+}
+
+private func apiConnectResponseAlert(_ r: ChatError) -> Alert? {
+ switch r {
+ case .error(.invalidConnReq):
+ mkAlert(
title: "Invalid connection link",
message: "Please check that you used the correct link or ask your contact to send you another one."
)
- return (nil, alert)
- case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
- let alert = mkAlert(
+ case .error(.unsupportedConnReq):
+ mkAlert(
+ title: "Unsupported connection link",
+ message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link."
+ )
+ case .errorAgent(.SMP(_, .AUTH)):
+ mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
- return (nil, alert)
- case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
+ case let .errorAgent(.SMP(_, .BLOCKED(info))):
+ Alert(
+ title: Text("Connection blocked"),
+ message: Text("Connection is blocked by server operator:\n\(info.reason.text)"),
+ primaryButton: .default(Text("Ok")),
+ secondaryButton: .default(Text("How it works")) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(contentModerationPostLink)
+ }
+ }
+ )
+ case .errorAgent(.SMP(_, .QUOTA)):
+ mkAlert(
+ title: "Undelivered messages",
+ message: "The connection reached the limit of undelivered messages, your contact may be offline."
+ )
+ case let .errorAgent(.INTERNAL(internalErr)):
if internalErr == "SEUniqueID" {
- let alert = mkAlert(
+ mkAlert(
title: "Already connected?",
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
)
- return (nil, alert)
+ } else {
+ nil
}
- default: ()
+ default: nil
}
- let alert = connectionErrorAlert(r)
- return (nil, alert)
}
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
@@ -742,13 +981,13 @@ func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
)
}
-private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
+private func connectionErrorAlert(_ r: APIResult) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert
} else {
return mkAlert(
title: "Connection error",
- message: "Error: \(responseError(r))"
+ message: "Error: \(responseError(r.unexpected))"
)
}
}
@@ -758,9 +997,9 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
logger.error("apiConnectContactViaAddress: no current user")
return (nil, nil)
}
- let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
- if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) }
- logger.error("apiConnectContactViaAddress error: \(responseError(r))")
+ let r: APIResult = await chatApiSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
+ if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) }
+ logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
let alert = connectionErrorAlert(r)
return (nil, alert)
}
@@ -769,11 +1008,11 @@ func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
- let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
- throw r
+ throw r.unexpected
}
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
@@ -787,9 +1026,9 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify:
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
- let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
- throw r
+ throw r.unexpected
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
@@ -840,9 +1079,9 @@ func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(noti
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
- let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
- throw r
+ throw r.unexpected
}
func clearChat(_ chat: Chat) async {
@@ -857,113 +1096,125 @@ func clearChat(_ chat: Chat) async {
func apiListContacts() throws -> [Contact] {
let userId = try currentUserId("apiListContacts")
- let r = chatSendCmdSync(.apiListContacts(userId: userId))
+ let r: ChatResponse1 = try chatSendCmdSync(.apiListContacts(userId: userId))
if case let .contactsList(_, contacts) = r { return contacts }
- throw r
+ throw r.unexpected
}
func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
let userId = try currentUserId("apiUpdateProfile")
- let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
+ let r: APIResult = await chatApiSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
switch r {
- case .userProfileNoChange: return (profile, [])
- case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts)
- case .chatCmdError(_, .errorStore(.duplicateName)): return nil;
- default: throw 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
}
}
func apiSetProfileAddress(on: Bool) async throws -> User? {
let userId = try currentUserId("apiSetProfileAddress")
- let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(user, _, _, _): return user
- default: throw r
+ default: throw r.unexpected
}
}
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
- let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact }
- throw r
+ throw r.unexpected
}
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
- let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(_, toContact) = r { return toContact }
- throw r
+ throw r.unexpected
+}
+
+func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? {
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
+ if case let .groupAliasUpdated(_, toGroup) = r { return toGroup }
+ throw r.unexpected
}
func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? {
- let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection }
- throw r
+ throw r.unexpected
}
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
- let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
- if case .cmdOk = r { return true }
- logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
- return false
+ do {
+ try await sendCommandOkResp(.apiSetUserUIThemes(userId: userId, themes: themes))
+ return true
+ } catch {
+ logger.error("apiSetUserUIThemes bad response: \(responseError(error))")
+ return false
+ }
}
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
- let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
- if case .cmdOk = r { return true }
- logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
- return false
+ do {
+ try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes))
+ return true
+ } catch {
+ logger.error("apiSetChatUIThemes bad response: \(responseError(error))")
+ return false
+ }
}
-func apiCreateUserAddress() async throws -> String {
+func apiCreateUserAddress(short: Bool) async throws -> CreatedConnLink {
let userId = try currentUserId("apiCreateUserAddress")
- let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
- if case let .userContactLinkCreated(_, connReq) = r { return connReq }
- throw r
+ let r: ChatResponse1 = try await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short))
+ if case let .userContactLinkCreated(_, connLink) = r { return connLink }
+ throw r.unexpected
}
func apiDeleteUserAddress() async throws -> User? {
let userId = try currentUserId("apiDeleteUserAddress")
- let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId))
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMyAddress(userId: userId))
if case let .userContactLinkDeleted(user) = r { return user }
- throw r
+ throw r.unexpected
}
func apiGetUserAddress() throws -> UserContactLink? {
let userId = try currentUserId("apiGetUserAddress")
- return try userAddressResponse(chatSendCmdSync(.apiShowMyAddress(userId: userId)))
+ return try userAddressResponse(chatApiSendCmdSync(.apiShowMyAddress(userId: userId)))
}
func apiGetUserAddressAsync() async throws -> UserContactLink? {
let userId = try currentUserId("apiGetUserAddressAsync")
- return try userAddressResponse(await chatSendCmd(.apiShowMyAddress(userId: userId)))
+ return try userAddressResponse(await chatApiSendCmd(.apiShowMyAddress(userId: userId)))
}
-private func userAddressResponse(_ r: ChatResponse) throws -> UserContactLink? {
+private func userAddressResponse(_ r: APIResult) throws -> UserContactLink? {
switch r {
- case let .userContactLink(_, contactLink): return contactLink
- case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
- default: throw r
+ case let .result(.userContactLink(_, contactLink)): return contactLink
+ case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil
+ default: throw r.unexpected
}
}
func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? {
let userId = try currentUserId("userAddressAutoAccept")
- let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept))
+ let r: APIResult = await chatApiSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept))
switch r {
- case let .userContactLinkUpdated(_, contactLink): return contactLink
- case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
- default: throw r
+ case let .result(.userContactLinkUpdated(_, contactLink)): return contactLink
+ case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil
+ default: throw r.unexpected
}
}
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
- let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
+ let r: APIResult = await chatApiSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
- if case let .acceptingContactRequest(_, contact) = r { return contact }
- if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
+ if case let .result(.acceptingContactRequest(_, contact)) = r { return contact }
+ if case .error(.errorAgent(.SMP(_, .AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
@@ -974,20 +1225,24 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
- message: "Error: \(responseError(r))"
+ message: "Error: \(responseError(r.unexpected))"
)
}
return nil
}
func apiRejectContactRequest(contactReqId: Int64) async throws {
- let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
+ let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case .contactRequestRejected = r { return }
- throw r
+ throw r.unexpected
}
-func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
- try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
+func apiChatRead(type: ChatType, id: Int64) async throws {
+ try await sendCommandOkResp(.apiChatRead(type: type, id: id))
+}
+
+func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
+ try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@@ -995,109 +1250,155 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
}
func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) {
- let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl)
- if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r {
+ let r: APIResult = await chatApiSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl: ctrl)
+ if case let .result(.sndStandaloneFileCreated(_, fileTransferMeta)) = r {
return (fileTransferMeta, nil)
} else {
- logger.error("uploadStandaloneFile error: \(String(describing: r))")
- return (nil, responseError(r))
+ let err = responseError(r.unexpected)
+ logger.error("uploadStandaloneFile error: \(err)")
+ return (nil, err)
}
}
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) {
- let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl)
- if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r {
+ let r: APIResult = await chatApiSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl: ctrl)
+ if case let .result(.rcvStandaloneFileCreated(_, rcvFileTransfer)) = r {
return (rcvFileTransfer, nil)
} else {
- logger.error("downloadStandaloneFile error: \(String(describing: r))")
- return (nil, responseError(r))
+ let err = responseError(r.unexpected)
+ logger.error("downloadStandaloneFile error: \(err)")
+ return (nil, err)
}
}
func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? {
- let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl)
- if case let .standaloneFileInfo(fileMeta) = r {
+ let r: APIResult = await chatApiSendCmd(.apiStandaloneFileInfo(url: url), ctrl: ctrl)
+ if case let .result(.standaloneFileInfo(fileMeta)) = r {
return fileMeta
} else {
- logger.error("standaloneFileInfo error: \(String(describing: r))")
+ logger.error("standaloneFileInfo error: \(responseError(r.unexpected))")
return nil
}
}
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
- if let chatItem = await apiReceiveFile(
- fileId: fileId,
- userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
- encrypted: privacyEncryptLocalFilesGroupDefault.get(),
+ await receiveFiles(
+ user: user,
+ fileIds: [fileId],
+ userApprovedRelays: userApprovedRelays,
auto: auto
- ) {
- await chatItemSimpleUpdate(user, chatItem)
- }
+ )
}
-func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
- let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
- let am = AlertManager.shared
- if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
- if case .rcvFileAcceptedSndCancelled = r {
- logger.debug("apiReceiveFile error: sender cancelled file transfer")
- if !auto {
- am.showAlertMsg(
- title: "Cannot receive file",
- message: "Sender cancelled file transfer."
+func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
+ var fileIdsToApprove: [Int64] = []
+ var srvsToApprove: Set = []
+ var otherFileErrs: [APIResult] = []
+
+ for fileId in fileIds {
+ let r: APIResult = await chatApiSendCmd(
+ .receiveFile(
+ fileId: fileId,
+ userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
+ encrypted: privacyEncryptLocalFilesGroupDefault.get(),
+ inline: nil
)
+ )
+ switch r {
+ case let .result(.rcvFileAccepted(_, chatItem)):
+ await chatItemSimpleUpdate(user, chatItem)
+ // TODO when aChatItem added
+ // case let .rcvFileAcceptedSndCancelled(user, aChatItem, _):
+ // await chatItemSimpleUpdate(user, aChatItem)
+ // Task { cleanupFile(aChatItem) }
+ case let .error(.error(.fileNotApproved(fileId, unknownServers))):
+ fileIdsToApprove.append(fileId)
+ srvsToApprove.formUnion(unknownServers)
+ default:
+ otherFileErrs.append(r)
}
- } else if let networkErrorAlert = networkErrorAlert(r) {
- logger.error("apiReceiveFile network error: \(String(describing: r))")
- if !auto {
- am.showAlert(networkErrorAlert)
- }
- } else {
- switch chatError(r) {
- case .fileCancelled:
- logger.debug("apiReceiveFile ignoring fileCancelled error")
- case .fileAlreadyReceiving:
- logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
- case let .fileNotApproved(fileId, unknownServers):
- logger.debug("apiReceiveFile fileNotApproved error")
- if !auto {
- let srvs = unknownServers.map { s in
+ }
+
+ if !auto {
+ let otherErrsStr = fileErrorStrs(otherFileErrs)
+ // If there are not approved files, alert is shown the same way both in case of singular and plural files reception
+ if !fileIdsToApprove.isEmpty {
+ let srvs = srvsToApprove
+ .map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
- am.showAlert(Alert(
- title: Text("Unknown servers!"),
- message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
- primaryButton: .default(
- Text("Download"),
- action: {
- Task {
- logger.debug("apiReceiveFile fileNotApproved alert - in Task")
- if let user = ChatModel.shared.currentUser {
- await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
- }
+ .sorted()
+ .joined(separator: ", ")
+ let fIds = fileIdsToApprove
+ await MainActor.run {
+ showAlert(
+ title: NSLocalizedString("Unknown servers!", comment: "alert title"),
+ message: (
+ String.localizedStringWithFormat(NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.", comment: "alert message"), srvs) +
+ (otherErrsStr != "" ? "\n\n" + String.localizedStringWithFormat(NSLocalizedString("Other file errors:\n%@", comment: "alert message"), otherErrsStr) : "")
+ ),
+ buttonTitle: NSLocalizedString("Download", comment: "alert button"),
+ buttonAction: {
+ Task {
+ logger.debug("apiReceiveFile fileNotApproved alert - in Task")
+ if let user = ChatModel.shared.currentUser {
+ await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true)
}
}
- ),
- secondaryButton: .cancel()
- ))
+ },
+ cancelButton: true
+ )
}
- default:
- logger.error("apiReceiveFile error: \(String(describing: r))")
- if !auto {
- am.showAlertMsg(
- title: "Error receiving file",
- message: "Error: \(responseError(r))"
+ } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
+ let errorResponse = otherFileErrs.first!
+ switch errorResponse {
+ case let .result(.rcvFileAcceptedSndCancelled(_, rcvFileTransfer)):
+ logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Cannot receive file", comment: "alert title"),
+ message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
+ )
+ }
+ case .error(.error(.fileCancelled)), .error(.error(.fileAlreadyReceiving)):
+ logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
+ default:
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error receiving file", comment: "alert title"),
+ message: responseError(errorResponse.unexpected)
+ )
+ }
+ }
+ } else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error receiving file", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("File errors:\n%@", comment: "alert message"), otherErrsStr)
)
}
}
}
- return nil
+
+ func fileErrorStrs(_ errs: [APIResult]) -> String {
+ var errStr = ""
+ if errs.count >= 1 {
+ errStr = String(describing: errs[0].unexpected)
+ }
+ if errs.count >= 2 {
+ errStr += "\n\(String(describing: errs[1].unexpected))"
+ }
+ if errs.count > 2 {
+ errStr += "\nand \(errs.count - 2) other error(s)"
+ }
+ return errStr
+ }
}
-
+
func cancelFile(user: User, fileId: Int64) async {
if let chatItem = await apiCancelFile(fileId: fileId) {
await chatItemSimpleUpdate(user, chatItem)
@@ -1106,12 +1407,12 @@ func cancelFile(user: User, fileId: Int64) async {
}
func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? {
- let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl)
+ let r: APIResult = await chatApiSendCmd(.cancelFile(fileId: fileId), ctrl: ctrl)
switch r {
- case let .sndFileCancelled(_, chatItem, _, _) : return chatItem
- case let .rcvFileCancelled(_, chatItem, _) : return chatItem
+ case let .result(.sndFileCancelled(_, chatItem, _, _)) : return chatItem
+ case let .result(.rcvFileCancelled(_, chatItem, _)) : return chatItem
default:
- logger.error("apiCancelFile error: \(String(describing: r))")
+ logger.error("apiCancelFile error: \(responseError(r.unexpected))")
return nil
}
}
@@ -1121,9 +1422,9 @@ func setLocalDeviceName(_ displayName: String) throws {
}
func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
- let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress))
+ let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress))
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
- throw r
+ throw r.unexpected
}
func findKnownRemoteCtrl() async throws {
@@ -1131,21 +1432,21 @@ func findKnownRemoteCtrl() async throws {
}
func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
- let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId))
+ let r: ChatResponse2 = try await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId))
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
- throw r
+ throw r.unexpected
}
func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo {
- let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode))
+ let r: ChatResponse2 = try await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode))
if case let .remoteCtrlConnected(rc) = r { return rc }
- throw r
+ throw r.unexpected
}
func listRemoteCtrls() throws -> [RemoteCtrlInfo] {
- let r = chatSendCmdSync(.listRemoteCtrls)
+ let r: ChatResponse2 = try chatSendCmdSync(.listRemoteCtrls)
if case let .remoteCtrlList(rcInfo) = r { return rcInfo }
- throw r
+ throw r.unexpected
}
func stopRemoteCtrl() async throws {
@@ -1156,8 +1457,8 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
-func networkErrorAlert(_ r: ChatResponse) -> Alert? {
- if let alert = getNetworkErrorAlert(r) {
+func networkErrorAlert(_ res: APIResult) -> Alert? {
+ if case let .error(e) = res, let alert = getNetworkErrorAlert(e) {
return mkAlert(title: alert.title, message: alert.message)
} else {
return nil
@@ -1219,15 +1520,15 @@ func apiEndCall(_ contact: Contact) async throws {
}
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
- let r = chatSendCmdSync(.apiGetCallInvitations)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
- throw r
+ throw r.unexpected
}
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
- let r = await chatSendCmd(.apiGetCallInvitations)
+ let r: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
- throw r
+ throw r.unexpected
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
@@ -1239,20 +1540,18 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
}
func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
- let r = chatSendCmdSync(.apiGetNetworkStatuses)
+ let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses)
if case let .networkStatuses(_, statuses) = r { return statuses }
- throw r
+ throw r.unexpected
}
-func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
+func markChatRead(_ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
- let minItemId = chat.chatStats.minUnreadItemId
- let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
- try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
+ try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
- withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
+ withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
}
}
if chat.chatStats.unreadChat {
@@ -1275,39 +1574,40 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
-func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
+func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
- logger.debug("apiMarkChatItemRead: \(cItem.id)")
- try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
- await ChatModel.shared.markChatItemRead(cInfo, cItem)
+ try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
+ DispatchQueue.main.async {
+ ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
+ }
} catch {
- logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
+ logger.error("apiChatItemsRead error: \(responseError(error))")
}
}
-private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws {
- let r = await chatSendCmd(cmd, ctrl)
+private func sendCommandOkResp(_ cmd: ChatCommand, ctrl: chat_ctrl? = nil) async throws {
+ let r: ChatResponse2 = try await chatSendCmd(cmd, ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
private func sendCommandOkRespSync(_ cmd: ChatCommand) throws {
- let r = chatSendCmdSync(cmd)
+ let r: ChatResponse2 = try chatSendCmdSync(cmd)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
let userId = try currentUserId("apiNewGroup")
- let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
+ let r: ChatResponse2 = try chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
- throw r
+ throw r.unexpected
}
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
- let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
+ let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
- throw r
+ throw r.unexpected
}
enum JoinGroupResult {
@@ -1317,31 +1617,31 @@ enum JoinGroupResult {
}
func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
- let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
+ let r: APIResult = await chatApiSendCmd(.apiJoinGroup(groupId: groupId))
switch r {
- case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
- case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
- case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
- default: throw r
+ case let .result(.userAcceptedGroupSent(_, groupInfo, _)): return .joined(groupInfo: groupInfo)
+ case .error(.errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
+ case .error(.errorStore(.groupNotFound)): return .groupNotFound
+ default: throw r.unexpected
}
}
-func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember {
- let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false)
- if case let .userDeletedMember(_, _, member) = r { return member }
- throw r
+func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] {
+ let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
+ if case let .userDeletedMembers(_, _, members, withMessages) = r { return members }
+ throw r.unexpected
}
-func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
- let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false)
- if case let .memberRoleUser(_, _, member, _, _) = r { return member }
- throw r
+func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] {
+ let r: ChatResponse2 = try await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false)
+ if case let .membersRoleUser(_, _, members, _) = r { return members }
+ throw r.unexpected
}
-func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember {
- let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false)
- if case let .memberBlockedForAllUser(_, _, member, _) = r { return member }
- throw r
+func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] {
+ let r: ChatResponse2 = try await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false)
+ if case let .membersBlockedForAllUser(_, _, members, _) = r { return members }
+ throw r.unexpected
}
func leaveGroup(_ groupId: Int64) async {
@@ -1354,14 +1654,15 @@ func leaveGroup(_ groupId: Int64) async {
}
func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
- let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false)
+ let r: ChatResponse2 = try await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false)
if case let .leftMemberUser(_, groupInfo) = r { return groupInfo }
- throw r
+ throw r.unexpected
}
+// use ChatModel's loadGroupMembers from views
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
- let r = await chatSendCmd(.apiListMembers(groupId: groupId))
- if case let .groupMembers(_, group) = r { return group.members }
+ let r: APIResult = await chatApiSendCmd(.apiListMembers(groupId: groupId))
+ if case let .result(.groupMembers(_, group)) = r { return group.members }
return []
}
@@ -1374,72 +1675,73 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
}
func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo {
- let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile))
+ let r: ChatResponse2 = try await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile))
if case let .groupUpdated(_, toGroup) = r { return toGroup }
- throw r
+ throw r.unexpected
}
-func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) {
- let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
- if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) }
- throw r
+func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) {
+ let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
+ let r: ChatResponse2 = try await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short))
+ if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) }
+ throw r.unexpected
}
-func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) {
- let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
- if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) }
- throw r
+func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
+ if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) }
+ throw r.unexpected
}
func apiDeleteGroupLink(_ groupId: Int64) async throws {
- let r = await chatSendCmd(.apiDeleteGroupLink(groupId: groupId))
+ let r: ChatResponse2 = try await chatSendCmd(.apiDeleteGroupLink(groupId: groupId))
if case .groupLinkDeleted = r { return }
- throw r
+ throw r.unexpected
}
-func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
- let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId))
+func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? {
+ let r: APIResult = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId))
switch r {
- case let .groupLink(_, _, connReq, memberRole):
- return (connReq, memberRole)
- case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)):
+ case let .result(.groupLink(_, _, connLink, memberRole)):
+ return (connLink, memberRole)
+ case .error(.errorStore(storeError: .groupLinkNotFound)):
return nil
- default: throw r
+ default: throw r.unexpected
}
}
func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact {
- let r = await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
+ let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
if case let .newMemberContact(_, contact, _, _) = r { return contact }
- throw r
+ throw r.unexpected
}
func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact {
- let r = await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay)
+ let r: ChatResponse2 = try await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay)
if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact }
- throw r
+ throw r.unexpected
}
func apiGetVersion() throws -> CoreVersionInfo {
- let r = chatSendCmdSync(.showVersion)
+ let r: ChatResponse2 = try chatSendCmdSync(.showVersion)
if case let .versionInfo(info, _, _) = r { return info }
- throw r
+ throw r.unexpected
}
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
- let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
+ let r: ChatResponse2 = try await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
- let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
+ let r: ChatResponse2 = try chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func resetAgentServersStats() async throws {
@@ -1469,6 +1771,16 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
+ m.conditions = try getServerOperatorsSync()
+ if shouldImportAppSettingsDefault.get() {
+ do {
+ let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
+ appSettings.importIntoApp()
+ shouldImportAppSettingsDefault.set(false)
+ } catch {
+ logger.error("Error while importing app settings: \(error)")
+ }
+ }
if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true)
@@ -1513,7 +1825,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
}
}
-func startChat(refreshInvitations: Bool = true) throws {
+func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
@@ -1532,13 +1844,15 @@ func startChat(refreshInvitations: Bool = true) throws {
if let token = m.deviceToken {
registerToken(token: token)
}
- withAnimation {
- let savedOnboardingStage = onboardingStageDefault.get()
- m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
- ? .step3_CreateSimpleXAddress
- : savedOnboardingStage
- if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
- m.setDeliveryReceipts = true
+ if !onboarding {
+ withAnimation {
+ let savedOnboardingStage = onboardingStageDefault.get()
+ m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
+ ? .step3_ChooseServerOperators
+ : savedOnboardingStage
+ if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
+ m.setDeliveryReceipts = true
+ }
}
}
}
@@ -1597,24 +1911,37 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
+ let tags = try apiGetChatTags()
m.updateChats(chats)
+ let tm = ChatTagsModel.shared
+ tm.activeFilter = nil
+ tm.userTags = tags
+ tm.updateChatTags(m.chats)
}
private func getUserChatDataAsync() async throws {
let m = ChatModel.shared
+ let tm = ChatTagsModel.shared
if m.currentUser != nil {
let userAddress = try await apiGetUserAddressAsync()
let chatItemTTL = try await getChatItemTTLAsync()
let chats = try await apiGetChatsAsync()
+ let tags = try await apiGetChatTagsAsync()
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
m.updateChats(chats)
+ tm.activeFilter = nil
+ tm.userTags = tags
+ tm.updateChatTags(m.chats)
}
} else {
await MainActor.run {
m.userAddress = nil
m.updateChats([])
+ tm.activeFilter = nil
+ tm.userTags = []
+ tm.presetTags = [:]
}
}
}
@@ -1624,7 +1951,7 @@ class ChatReceiver {
private var receiveMessages = true
private var _lastMsgTime = Date.now
- var messagesChannel: ((ChatResponse) -> Void)? = nil
+ var messagesChannel: ((APIResult) -> Void)? = nil
static let shared = ChatReceiver()
@@ -1642,7 +1969,12 @@ class ChatReceiver {
while self.receiveMessages {
if let msg = await chatRecvMsg() {
self._lastMsgTime = .now
- await processReceivedMsg(msg)
+ Task { await TerminalItems.shared.addResult(msg) }
+ switch msg {
+ case let .result(evt): await processReceivedMsg(evt)
+ case let .error(err): logger.debug("chatRecvMsg error: \(responseError(err))")
+ case let .invalid(type, json): logger.debug("chatRecvMsg event: * \(type) \(dataToString(json))")
+ }
if let messagesChannel {
messagesChannel(msg)
}
@@ -1659,10 +1991,7 @@ class ChatReceiver {
}
}
-func processReceivedMsg(_ res: ChatResponse) async {
- Task {
- await TerminalItems.shared.add(.resp(.now, res))
- }
+func processReceivedMsg(_ res: ChatEvent) async {
let m = ChatModel.shared
let n = NetworkModel.shared
logger.debug("processReceivedMsg: \(res.responseType)")
@@ -1775,36 +2104,48 @@ func processReceivedMsg(_ res: ChatResponse) async {
n.networkStatuses = ns
}
}
- case let .newChatItem(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- await MainActor.run {
- if active(user) {
- m.addChatItem(cInfo, cItem)
- } else if cItem.isRcvNew && cInfo.ntfsEnabled {
- m.increaseUnreadCounter(user: user)
+ case let .newChatItems(user, chatItems):
+ for chatItem in chatItems {
+ let cInfo = chatItem.chatInfo
+ let cItem = chatItem.chatItem
+ await MainActor.run {
+ if active(user) {
+ m.addChatItem(cInfo, cItem)
+ if cItem.isActiveReport {
+ m.increaseGroupReportsCounter(cInfo.id)
+ }
+ } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
+ m.increaseUnreadCounter(user: user)
+ }
+ }
+ if let file = cItem.autoReceiveFile() {
+ Task {
+ await receiveFile(user: user, fileId: file.fileId, auto: true)
+ }
+ }
+ if cItem.showNotification {
+ NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
- if let file = cItem.autoReceiveFile() {
- Task {
- await receiveFile(user: user, fileId: file.fileId, auto: true)
+ case let .chatItemsStatusesUpdated(user, chatItems):
+ for chatItem in chatItems {
+ let cInfo = chatItem.chatInfo
+ let cItem = chatItem.chatItem
+ if !cItem.isDeletedContent && active(user) {
+ await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
}
- }
- if cItem.showNotification {
- NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
- }
- case let .chatItemStatusUpdated(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if !cItem.isDeletedContent && active(user) {
- await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
- }
- if let endTask = m.messageDelivery[cItem.id] {
- switch cItem.meta.itemStatus {
- case .sndSent: endTask()
- case .sndErrorAuth: endTask()
- case .sndError: endTask()
- default: ()
+ if let endTask = m.messageDelivery[cItem.id] {
+ switch cItem.meta.itemStatus {
+ case .sndNew: ()
+ case .sndSent: endTask()
+ case .sndRcvd: endTask()
+ case .sndErrorAuth: endTask()
+ case .sndError: endTask()
+ case .sndWarning: endTask()
+ case .rcvNew: ()
+ case .rcvRead: ()
+ case .invalid: ()
+ }
}
}
case let .chatItemUpdated(user, aChatItem):
@@ -1818,7 +2159,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .chatItemsDeleted(user, items, _):
if !active(user) {
for item in items {
- if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
+ let d = item.deletedChatItem
+ if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
@@ -1834,8 +2176,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
+ if item.deletedChatItem.chatItem.isActiveReport {
+ m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id)
+ }
}
}
+ case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
+ await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {
@@ -1855,7 +2202,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
-
+
await MainActor.run {
m.updateGroup(groupInfo)
if let hostConn = hostMember.activeConn {
@@ -1863,22 +2210,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(hostConn.id)
}
}
+ case let .businessLinkConnecting(user, groupInfo, _, fromContact):
+ if !active(user) { return }
+
+ await MainActor.run {
+ m.updateGroup(groupInfo)
+ }
+ if m.chatId == fromContact.id {
+ ItemsModel.shared.loadOpenChat(groupInfo.id)
+ }
+ await MainActor.run {
+ m.removeChat(fromContact.id)
+ }
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
}
- case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
+ case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
+ if withMessages {
+ m.removeMemberItems(groupInfo.membership, byMember: member, groupInfo)
+ }
}
}
- case let .deletedMember(user, groupInfo, _, deletedMember):
+ case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, deletedMember)
+ if withMessages {
+ m.removeMemberItems(deletedMember, byMember: byMember, groupInfo)
+ }
}
}
case let .leftMember(user, groupInfo, member):
@@ -1944,6 +2309,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
await chatItemSimpleUpdate(user, aChatItem)
+// TODO when aChatItem added
+// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for XFTP files auto-accepted from NSE
+// await chatItemSimpleUpdate(user, aChatItem)
+// Task { cleanupFile(aChatItem) }
case let .rcvFileStart(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
@@ -1998,7 +2367,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
- call.peerMedia = callType.media
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -2167,6 +2535,43 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
}
}
+func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async {
+ let m = ChatModel.shared
+ if !active(user) {
+ do {
+ let users = try listUsers()
+ await MainActor.run {
+ m.users = users
+ }
+ } catch {
+ logger.error("Error loading users: \(error)")
+ }
+ return
+ }
+ let im = ItemsModel.shared
+ let cInfo = ChatInfo.group(groupInfo: groupInfo)
+ await MainActor.run {
+ m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
+ }
+ var notFound = chatItemIDs.count
+ for ci in im.reversedChatItems {
+ if chatItemIDs.contains(ci.id) {
+ let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId {
+ CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_)
+ } else {
+ CIDeleted.deleted(deletedTs: Date.now)
+ }
+ await MainActor.run {
+ var newItem = ci
+ newItem.meta.itemDeleted = deleted
+ _ = m.upsertChatItem(cInfo, newItem)
+ }
+ notFound -= 1
+ if notFound == 0 { break }
+ }
+ }
+}
+
func refreshCallInvitations() async throws {
let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations()
@@ -2182,9 +2587,11 @@ func refreshCallInvitations() async throws {
}
}
-func justRefreshCallInvitations() throws {
+func justRefreshCallInvitations() async throws {
let callInvitations = try apiGetCallInvitationsSync()
- ChatModel.shared.callInvitations = callsByChat(callInvitations)
+ await MainActor.run {
+ ChatModel.shared.callInvitations = callsByChat(callInvitations)
+ }
}
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
@@ -2194,12 +2601,13 @@ private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: Rcv
}
func activateCall(_ callInvitation: RcvCallInvitation) {
- if !callInvitation.user.showNotifications { return }
let m = ChatModel.shared
+ logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
+ if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
DispatchQueue.main.async {
- m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
+ m.callInvitations[callInvitation.contact.id]?.callUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 7f2c3b5866..f8d69c5fc8 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -19,6 +19,7 @@ struct SimpleXApp: App {
@Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
+ @State private var appOpenUrlLater: URL?
init() {
DispatchQueue.global(qos: .background).sync {
@@ -42,7 +43,11 @@ struct SimpleXApp: App {
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
- chatModel.appOpenUrl = url
+ if AppChatState.shared.value == .active {
+ chatModel.appOpenUrl = url
+ } else {
+ appOpenUrlLater = url
+ }
}
.onAppear() {
// Present screen for continue migration if it wasn't finished yet
@@ -82,12 +87,27 @@ struct SimpleXApp: App {
if appState != .stopped {
startChatAndActivate {
- if appState.inactive && chatModel.chatRunning == true {
- Task {
- await updateChats()
- if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
- await updateCallInvitations()
+ if chatModel.chatRunning == true {
+ if let ntfResponse = chatModel.notificationResponse {
+ chatModel.notificationResponse = nil
+ NtfManager.shared.processNotificationResponse(ntfResponse)
+ }
+ if appState.inactive {
+ Task {
+ await updateChats()
+ if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
+ await updateCallInvitations()
+ }
+ if let url = appOpenUrlLater {
+ await MainActor.run {
+ appOpenUrlLater = nil
+ chatModel.appOpenUrl = url
+ }
+ }
}
+ } else if let url = appOpenUrlLater {
+ appOpenUrlLater = nil
+ chatModel.appOpenUrl = url
}
}
}
@@ -137,7 +157,8 @@ struct SimpleXApp: App {
let chats = try await apiGetChatsAsync()
await MainActor.run { chatModel.updateChats(chats) }
if let id = chatModel.chatId,
- let chat = chatModel.getChat(id) {
+ let chat = chatModel.getChat(id),
+ !NtfManager.shared.navigatingToChat {
Task { await loadChat(chat: chat, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift
index e2641eb8dd..de67390026 100644
--- a/apps/ios/Shared/Theme/Theme.swift
+++ b/apps/ios/Shared/Theme/Theme.swift
@@ -102,7 +102,7 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
- if let parsed = UIImage(base64Encoded: image),
+ if let parsed = imageFromBase64(image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
@@ -122,7 +122,7 @@ extension ThemeWallpaper {
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
- let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
+ let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift
index 9d648750d1..4166619d04 100644
--- a/apps/ios/Shared/Theme/ThemeManager.swift
+++ b/apps/ios/Shared/Theme/ThemeManager.swift
@@ -197,7 +197,7 @@ class ThemeManager {
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
- applyTheme(nonSystemThemeName)
+ applyTheme(currentThemeDefault.get())
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding) -> Bool {
diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift
index 97415018bf..ab7a47b944 100644
--- a/apps/ios/Shared/Views/Call/ActiveCallView.swift
+++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift
@@ -17,8 +17,8 @@ struct ActiveCallView: View {
@ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil
- @State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil
+ @State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
@Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
@@ -27,24 +27,39 @@ struct ActiveCallView: View {
var body: some View {
ZStack(alignment: .topLeading) {
ZStack(alignment: .bottom) {
- if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
+ if let client = client, call.hasVideo {
GeometryReader { g in
let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) {
- CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
- CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
- .cornerRadius(10)
- .frame(width: width, height: width / (localRendererAspectRatio ?? 1))
- .padding([.top, .trailing], 17)
ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001))
+
+ CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
+ .onTapGesture {
+ remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
+ }
+
+ Group {
+ let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
+ if localVideoTrack != nil {
+ CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
+ .onDisappear {
+ localRendererAspectRatio = nil
+ }
+ } else {
+ Rectangle().fill(.black)
+ }
+ }
+ .cornerRadius(10)
+ .frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
+ .padding([.top, .trailing], 17)
}
}
}
- if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
+ if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) {
ActiveCallOverlay(call: call, client: client)
}
}
@@ -54,6 +69,9 @@ struct ActiveCallView: View {
.onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
+ Task {
+ await askRequiredPermissions()
+ }
createWebRTCClient()
dismissAllSheets()
hideKeyboard()
@@ -84,7 +102,7 @@ struct ActiveCallView: View {
private func createWebRTCClient() {
if client == nil && canConnectCall {
- client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
+ client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
Task {
await m.callCommand.setClient(client)
}
@@ -99,7 +117,7 @@ struct ActiveCallView: View {
logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp {
case let .capabilities(capabilities):
- let callType = CallType(media: call.localMedia, capabilities: capabilities)
+ let callType = CallType(media: call.initialCallType, capabilities: capabilities)
Task {
do {
try await apiSendCallInvitation(call.contact, callType)
@@ -110,7 +128,7 @@ struct ActiveCallView: View {
call.callState = .invitationSent
call.localCapabilities = capabilities
}
- if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
+ if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
CallSoundsPlayer.shared.startConnectingCallSound()
@@ -120,7 +138,7 @@ struct ActiveCallView: View {
Task {
do {
try await apiSendCallOffer(call.contact, offer, iceCandidates,
- media: call.localMedia, capabilities: capabilities)
+ media: call.initialCallType, capabilities: capabilities)
} catch {
logger.error("apiSendCallOffer \(responseError(error))")
}
@@ -164,6 +182,9 @@ struct ActiveCallView: View {
}
if state.connectionState == "closed" {
closeCallView(client)
+ if let callUUID = m.activeCall?.callUUID {
+ CallController.shared.endCall(callUUID: callUUID)
+ }
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
@@ -182,10 +203,18 @@ struct ActiveCallView: View {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
+ case let .peerMedia(source, enabled):
+ switch source {
+ case .mic: call.peerMediaSources.mic = enabled
+ case .camera: call.peerMediaSources.camera = enabled
+ case .screenAudio: call.peerMediaSources.screenAudio = enabled
+ case .screenVideo: call.peerMediaSources.screenVideo = enabled
+ case .unknown: ()
+ }
case .ended:
closeCallView(client)
call.callState = .ended
- if let uuid = call.callkitUUID {
+ if let uuid = call.callUUID {
CallController.shared.endCall(callUUID: uuid)
}
case .ok:
@@ -214,16 +243,38 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil
return
}
- if case let .chatItemStatusUpdated(_, msg) = msg,
- msg.chatInfo.id == call.contact.id,
- case .sndCall = msg.chatItem.content,
- case .sndRcvd = msg.chatItem.meta.itemStatus {
+ if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg,
+ chatItems.contains(where: { ci in
+ ci.chatInfo.id == call.contact.id &&
+ ci.chatItem.content.isSndCall &&
+ ci.chatItem.meta.itemStatus.isSndRcvd
+ }) {
CallSoundsPlayer.shared.startInCallSound()
ChatReceiver.shared.messagesChannel = nil
}
}
}
+ private func askRequiredPermissions() async {
+ let mic = await WebRTCClient.isAuthorized(for: .audio)
+ await MainActor.run {
+ call.localMediaSources.mic = mic
+ }
+ let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
+ var camera = call.initialCallType == .audio || cameraAuthorized
+ if call.initialCallType == .video && !cameraAuthorized {
+ camera = await WebRTCClient.isAuthorized(for: .video)
+ await MainActor.run {
+ if camera, let client {
+ client.setCameraEnabled(true)
+ }
+ }
+ }
+ if !mic || !camera {
+ WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
+ }
+ }
+
private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil {
m.showCallView = false
@@ -239,44 +290,16 @@ struct ActiveCallOverlay: View {
var body: some View {
VStack {
- switch call.localMedia {
- case .video:
+ switch call.hasVideo {
+ case true:
videoCallInfoView(call)
.foregroundColor(.white)
.opacity(0.8)
- .padding()
-
- Spacer()
-
- HStack {
- toggleAudioButton()
- Spacer()
- if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
- toggleSpeakerButton()
- .frame(width: 40, height: 40)
- } else if call.hasMedia {
- AudioDevicePicker()
- .scaleEffect(2)
- .frame(maxWidth: 40, maxHeight: 40)
- } else {
- Color.clear.frame(width: 40, height: 40)
- }
- Spacer()
- endCallButton()
- Spacer()
- if call.videoEnabled {
- flipCameraButton()
- } else {
- Color.clear.frame(width: 40, height: 40)
- }
- Spacer()
- toggleVideoButton()
- }
- .padding(.horizontal, 20)
- .padding(.bottom, 16)
- .frame(maxWidth: .infinity, alignment: .center)
-
- case .audio:
+ .padding(.horizontal)
+ // Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
+ // Otherwise, bigger padding is added by SwiftUI when switching call types
+ .padding(.vertical, 10)
+ case false:
ZStack(alignment: .topLeading) {
Button {
chatModel.activeCallViewIsCollapsed = true
@@ -291,35 +314,32 @@ struct ActiveCallOverlay: View {
}
.foregroundColor(.white)
.opacity(0.8)
- .padding()
+ .padding(.horizontal)
+ .padding(.vertical, 10)
.frame(maxHeight: .infinity)
}
-
- Spacer()
-
- ZStack(alignment: .bottom) {
- toggleAudioButton()
- .frame(maxWidth: .infinity, alignment: .leading)
- endCallButton()
- // Check if the only input is microphone. And in this case show toggle button,
- // If there are more inputs, it probably means something like bluetooth headphones are available
- // and in this case show iOS button for choosing different output.
- // There is no way to get available outputs, only inputs
- if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
- toggleSpeakerButton()
- .frame(maxWidth: .infinity, alignment: .trailing)
- } else if call.hasMedia {
- AudioDevicePicker()
- .scaleEffect(2)
- .frame(maxWidth: 50, maxHeight: 40)
- .frame(maxWidth: .infinity, alignment: .trailing)
- } else {
- Color.clear.frame(width: 50, height: 40)
- }
- }
- .padding(.bottom, 60)
- .padding(.horizontal, 48)
}
+
+ Spacer()
+
+ HStack {
+ toggleMicButton()
+ Spacer()
+ audioDeviceButton()
+ Spacer()
+ endCallButton()
+ Spacer()
+ if call.localMediaSources.camera {
+ flipCameraButton()
+ } else {
+ Color.clear.frame(width: 60, height: 60)
+ }
+ Spacer()
+ toggleCameraButton()
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 16)
+ .frame(maxWidth: 440, alignment: .center)
}
.frame(maxWidth: .infinity)
.onAppear {
@@ -341,7 +361,7 @@ struct ActiveCallOverlay: View {
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
- Text("(") + Text(connInfo.text) + Text(")")
+ Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
}
}
}
@@ -370,7 +390,7 @@ struct ActiveCallOverlay: View {
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
- Text("(") + Text(connInfo.text) + Text(")")
+ Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
}
}
}
@@ -381,36 +401,54 @@ struct ActiveCallOverlay: View {
private func endCallButton() -> some View {
let cc = CallController.shared
- return callButton("phone.down.fill", width: 60, height: 60) {
- if let uuid = call.callkitUUID {
+ return callButton("phone.down.fill", .red, padding: 10) {
+ if let uuid = call.callUUID {
cc.endCall(callUUID: uuid)
} else {
cc.endCall(call: call) {}
}
}
- .foregroundColor(.red)
}
- private func toggleAudioButton() -> some View {
- controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
+ private func toggleMicButton() -> some View {
+ controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) {
Task {
- client.setAudioEnabled(!call.audioEnabled)
- DispatchQueue.main.async {
- call.audioEnabled = !call.audioEnabled
- }
+ if await WebRTCClient.isAuthorized(for: .audio) {
+ client.setAudioEnabled(!call.localMediaSources.mic)
+ } else { WebRTCClient.showUnauthorizedAlert(for: .audio) }
+ }
+ }
+ }
+
+ func audioDeviceButton() -> some View {
+ // Check if the only input is microphone. And in this case show toggle button,
+ // If there are more inputs, it probably means something like bluetooth headphones are available
+ // and in this case show iOS button for choosing different output.
+ // There is no way to get available outputs, only inputs
+ Group {
+ if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
+ toggleSpeakerButton()
+ } else {
+ audioDevicePickerButton()
+ }
+ }
+ .onChange(of: call.localMediaSources.hasVideo) { hasVideo in
+ let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
+ let speakerEnabled = current == .builtInSpeaker
+ let receiverEnabled = current == .builtInReceiver
+ // react automatically only when receiver were selected, otherwise keep an external device selected
+ if !speakerEnabled && hasVideo && receiverEnabled {
+ client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
+ call.speakerEnabled = !speakerEnabled
}
}
}
private func toggleSpeakerButton() -> some View {
- controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
- Task {
- let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
- client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
- DispatchQueue.main.async {
- call.speakerEnabled = !speakerEnabled
- }
- }
+ controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) {
+ let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
+ client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
+ call.speakerEnabled = !speakerEnabled
}
.onAppear {
deviceManager.call = call
@@ -418,53 +456,67 @@ struct ActiveCallOverlay: View {
}
}
- private func toggleVideoButton() -> some View {
- controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
+ private func toggleCameraButton() -> some View {
+ controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) {
Task {
- client.setVideoEnabled(!call.videoEnabled)
- DispatchQueue.main.async {
- call.videoEnabled = !call.videoEnabled
- }
+ if await WebRTCClient.isAuthorized(for: .video) {
+ client.setCameraEnabled(!call.localMediaSources.camera)
+ } else { WebRTCClient.showUnauthorizedAlert(for: .video) }
}
}
+ .disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
}
- @ViewBuilder private func flipCameraButton() -> some View {
- controlButton(call, "arrow.triangle.2.circlepath") {
+ private func flipCameraButton() -> some View {
+ controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
Task {
- client.flipCamera()
+ if await WebRTCClient.isAuthorized(for: .video) {
+ client.flipCamera()
+ }
}
}
}
- @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
- if call.hasMedia {
- callButton(imageName, width: 50, height: 38, perform)
- .foregroundColor(.white)
- .opacity(0.85)
- } else {
- Color.clear.frame(width: 50, height: 38)
- }
+ private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
+ callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
}
- private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
+ private func audioDevicePickerButton() -> some View {
+ AudioDevicePicker()
+ .opacity(0.8)
+ .scaleEffect(2)
+ .padding(10)
+ .frame(width: 60, height: 60)
+ .background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
+ .clipShape(.circle)
+ }
+
+ private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
Image(systemName: imageName)
.resizable()
.scaledToFit()
- .frame(maxWidth: width, maxHeight: height)
+ .padding(padding)
+ .frame(width: 60, height: 60)
+ .background(background)
}
+ .foregroundColor(whiteColorWithAlpha)
+ .clipShape(.circle)
+ }
+
+ private var whiteColorWithAlpha: Color {
+ get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
}
}
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
- ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
+ ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil)))
.background(.black)
- ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
+ ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil)))
.background(.black)
}
}
diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift
index a8a91057fa..1f28180e87 100644
--- a/apps/ios/Shared/Views/Call/CallController.swift
+++ b/apps/ios/Shared/Views/Call/CallController.swift
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction")
- if callManager.startOutgoingCall(callUUID: action.callUUID) {
+ if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else {
@@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction")
- if callManager.answerIncomingCall(callUUID: action.callUUID) {
- // WebRTC call should be in connected state to fulfill.
- // Otherwise no audio and mic working on lockscreen
- fulfillOnConnect = action
- } else {
- action.fail()
+ Task {
+ let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
+ logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
+ if !chatIsReady {
+ action.fail()
+ return
+ }
+ if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
+ try? await justRefreshCallInvitations()
+ logger.debug("CallController: updated call invitations chat")
+ }
+ await MainActor.run {
+ logger.debug("CallController.provider will answer on call")
+
+ if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
+ logger.debug("CallController.provider answered on call")
+ // WebRTC call should be in connected state to fulfill.
+ // Otherwise no audio and mic working on lockscreen
+ fulfillOnConnect = action
+ } else {
+ logger.debug("CallController.provider will fail the call")
+ action.fail()
+ }
+ }
}
}
@@ -75,7 +93,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state
fulfillOnConnect?.fail()
fulfillOnConnect = nil
- callManager.endCall(callUUID: action.callUUID) { ok in
+ callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
if ok {
action.fulfill()
} else {
@@ -86,7 +104,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
- if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
+ if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
} else {
action.fail()
@@ -103,8 +121,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
do {
- let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
- if supportsVideo {
+ let hasVideo = ChatModel.shared.activeCall?.hasVideo == true
+ if hasVideo {
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} else {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
@@ -115,7 +133,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
if let preferred = audioSession.preferredInputDevice() {
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
- } else if supportsVideo {
+ } else if hasVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
}
}
@@ -156,6 +174,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
+ private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
+ logger.debug("CallController waiting until chat started")
+ var t: UInt64 = 0
+ repeat {
+ if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
+ return true
+ }
+ _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
+ t += stepMs
+ } while t < timeoutMs
+ return false
+ }
+
@objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
@@ -171,32 +202,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
self.reportExpiredCall(payload: payload, completion)
return
}
- if (!ChatModel.shared.chatInitialized) {
- logger.debug("CallController: initializing chat")
- do {
- try initializeChat(start: true, refreshInvitations: false)
- } catch let error {
- logger.error("CallController: initializing chat error: \(error)")
- self.reportExpiredCall(payload: payload, completion)
- return
- }
- }
- logger.debug("CallController: initialized chat")
- startChatForCall()
- logger.debug("CallController: started chat")
- self.shouldSuspendChat = true
- // There are no invitations in the model, as it was processed by NSE
- try? justRefreshCallInvitations()
- logger.debug("CallController: updated call invitations chat")
- // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload
let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String,
- let invitation = m.callInvitations[contactId] {
- let update = self.cxCallUpdate(invitation: invitation)
- if let uuid = invitation.callkitUUID {
+ let displayName = payload.dictionaryPayload["displayName"] as? String,
+ let callUUID = payload.dictionaryPayload["callUUID"] as? String,
+ let uuid = UUID(uuidString: callUUID),
+ let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
+ let mediaStr = payload.dictionaryPayload["media"] as? String,
+ let media = CallMediaType(rawValue: mediaStr) {
+ let update = self.cxCallUpdate(contactId, displayName, media)
+ let callTs = Date(timeIntervalSince1970: callTsInterval)
+ if callTs.timeIntervalSinceNow >= -180 {
logger.debug("CallController: report pushkit call via CallKit")
- let update = self.cxCallUpdate(invitation: invitation)
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil {
m.callInvitations.removeValue(forKey: contactId)
@@ -205,11 +223,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion()
}
} else {
+ logger.debug("CallController will expire call 1")
self.reportExpiredCall(update: update, completion)
}
} else {
+ logger.debug("CallController will expire call 2")
self.reportExpiredCall(payload: payload, completion)
}
+
+ //DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
+ if (!ChatModel.shared.chatInitialized) {
+ logger.debug("CallController: initializing chat")
+ do {
+ try initializeChat(start: true, refreshInvitations: false)
+ } catch let error {
+ logger.error("CallController: initializing chat error: \(error)")
+ if let call = ChatModel.shared.activeCall {
+ self.endCall(call: call, completed: completion)
+ }
+ return
+ }
+ }
+ logger.debug("CallController: initialized chat")
+ startChatForCall()
+ logger.debug("CallController: started chat")
+ self.shouldSuspendChat = true
}
// This function fulfils the requirement to always report a call when PushKit notification is received,
@@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
- logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
- if CallController.useCallKit(), let uuid = invitation.callkitUUID {
+ logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@@ -261,6 +299,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update
}
+ private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
+ let update = CXCallUpdate()
+ update.remoteHandle = CXHandle(type: .generic, value: contactId)
+ update.hasVideo = media == .video
+ update.localizedCallerName = displayName
+ return update
+ }
+
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() {
@@ -272,14 +318,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected")
- if CallController.useCallKit(), let uuid = call.callkitUUID {
+ if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
}
}
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended")
- if CallController.useCallKit(), let uuid = invitation.callkitUUID {
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil
@@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended")
- if CallController.useCallKit(), let uuid = call.callkitUUID {
+ if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall")
- let uuid = callManager.newOutgoingCall(contact, media)
+ let callUUID = callManager.newOutgoingCall(contact, media)
+ guard let uuid = UUID(uuidString: callUUID) else {
+ return
+ }
if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle)
@@ -307,19 +356,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update)
}
- } else if callManager.startOutgoingCall(callUUID: uuid) {
- if callManager.startOutgoingCall(callUUID: uuid) {
- logger.debug("CallController.startCall: call started")
- } else {
- logger.error("CallController.startCall: no active call")
- }
+ } else if callManager.startOutgoingCall(callUUID: callUUID) {
+ logger.debug("CallController.startCall: call started")
+ } else {
+ logger.error("CallController.startCall: no active call")
}
}
func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call")
- if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
- requestTransaction(with: CXAnswerCallAction(call: callUUID))
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
+ requestTransaction(with: CXAnswerCallAction(call: uuid))
} else {
callManager.answerIncomingCall(invitation: invitation)
}
@@ -328,10 +375,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
- func endCall(callUUID: UUID) {
- logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
+ func endCall(callUUID: String) {
+ let uuid = UUID(uuidString: callUUID)
+ logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() {
- requestTransaction(with: CXEndCallAction(call: callUUID))
+ if let uuid {
+ requestTransaction(with: CXEndCallAction(call: uuid))
+ }
} else {
callManager.endCall(callUUID: callUUID) { ok in
if ok {
diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift
index a6d5ea17c4..a3e1df2301 100644
--- a/apps/ios/Shared/Views/Call/CallManager.swift
+++ b/apps/ios/Shared/Views/Call/CallManager.swift
@@ -10,25 +10,25 @@ import Foundation
import SimpleXChat
class CallManager {
- func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
- let uuid = UUID()
- let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
+ func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
+ let uuid = UUID().uuidString.lowercased()
+ let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media)
call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call
return uuid
}
- func startOutgoingCall(callUUID: UUID) -> Bool {
+ func startOutgoingCall(callUUID: String) -> Bool {
let m = ChatModel.shared
- if let call = m.activeCall, call.callkitUUID == callUUID {
+ if let call = m.activeCall, call.callUUID == callUUID {
m.showCallView = true
- Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
+ Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) }
return true
}
return false
}
- func answerIncomingCall(callUUID: UUID) -> Bool {
+ func answerIncomingCall(callUUID: String) -> Bool {
if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation)
return true
@@ -42,9 +42,9 @@ class CallManager {
let call = Call(
direction: .incoming,
contact: invitation.contact,
- callkitUUID: invitation.callkitUUID,
+ callUUID: invitation.callUUID,
callState: .invitationAccepted,
- localMedia: invitation.callType.media,
+ initialCallType: invitation.callType.media,
sharedKey: invitation.sharedKey
)
call.speakerEnabled = invitation.callType.media == .video
@@ -68,17 +68,17 @@ class CallManager {
}
}
- func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
- if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
+ func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool {
+ if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
let m = ChatModel.shared
- Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
+ Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) }
return true
}
return false
}
- func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
- if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
+ func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
+ if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) }
@@ -126,8 +126,8 @@ class CallManager {
}
}
- private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
- if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
+ private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
+ if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
return invitation
}
return nil
diff --git a/apps/ios/Shared/Views/Call/CallViewRenderers.swift b/apps/ios/Shared/Views/Call/CallViewRenderers.swift
index a3201d9351..e779093a24 100644
--- a/apps/ios/Shared/Views/Call/CallViewRenderers.swift
+++ b/apps/ios/Shared/Views/Call/CallViewRenderers.swift
@@ -10,40 +10,49 @@ import AVKit
struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient
- var activeCall: Binding
+ @ObservedObject var call: Call
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool
+ @Binding var contentMode: UIView.ContentMode
@Binding var pipShown: Bool
- init(client: WebRTCClient, activeCall: Binding, activeCallViewIsCollapsed: Binding, pipShown: Binding) {
- self.client = client
- self.activeCall = activeCall
- self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
- self._pipShown = pipShown
- }
-
func makeUIView(context: Context) -> UIView {
let view = UIView()
- if let call = activeCall.wrappedValue {
- let remoteRenderer = RTCMTLVideoView(frame: view.frame)
- remoteRenderer.videoContentMode = .scaleAspectFill
- client.addRemoteRenderer(call, remoteRenderer)
- addSubviewAndResize(remoteRenderer, into: view)
+ let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
+ remoteCameraRenderer.videoContentMode = contentMode
+ remoteCameraRenderer.tag = 0
- if AVPictureInPictureController.isPictureInPictureSupported() {
- makeViewWithRTCRenderer(call, remoteRenderer, view, context)
- }
+ let screenVideo = call.peerMediaSources.screenVideo
+ let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
+ remoteScreenRenderer.videoContentMode = contentMode
+ remoteScreenRenderer.tag = 1
+ remoteScreenRenderer.alpha = screenVideo ? 1 : 0
+
+ context.coordinator.cameraRenderer = remoteCameraRenderer
+ context.coordinator.screenRenderer = remoteScreenRenderer
+ client.addRemoteCameraRenderer(remoteCameraRenderer)
+ client.addRemoteScreenRenderer(remoteScreenRenderer)
+ if screenVideo {
+ addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
+ } else {
+ addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
+ }
+
+ if AVPictureInPictureController.isPictureInPictureSupported() {
+ makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
}
return view
}
- func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
- let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
- pipRemoteRenderer.videoContentMode = .scaleAspectFill
-
+ func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
+ let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
+ pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill
+
+ let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
+ pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
+
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
- addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController
@@ -55,7 +64,9 @@ struct CallViewRemote: UIViewRepresentable {
context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in
if show {
- client.addRemoteRenderer(call, pipRemoteRenderer)
+ client.addRemoteCameraRenderer(pipRemoteCameraRenderer)
+ client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
+ context.coordinator.relayout()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true
}
@@ -67,13 +78,29 @@ struct CallViewRemote: UIViewRepresentable {
}
context.coordinator.didShowHide = { show in
if show {
- remoteRenderer.isHidden = true
+ remoteCameraRenderer.isHidden = true
+ remoteScreenRenderer.isHidden = true
} else {
- client.removeRemoteRenderer(call, pipRemoteRenderer)
- remoteRenderer.isHidden = false
+ client.removeRemoteCameraRenderer(pipRemoteCameraRenderer)
+ client.removeRemoteScreenRenderer(pipRemoteScreenRenderer)
+ remoteCameraRenderer.isHidden = false
+ remoteScreenRenderer.isHidden = false
}
pipShown = show
}
+ context.coordinator.relayout = {
+ let camera = call.peerMediaSources.camera
+ let screenVideo = call.peerMediaSources.screenVideo
+ pipRemoteCameraRenderer.alpha = camera ? 1 : 0
+ pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
+ if screenVideo {
+ addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
+ } else {
+ addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
+ }
+ (pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
+ (pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
+ }
DispatchQueue.main.async {
enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ {
@@ -88,24 +115,50 @@ struct CallViewRemote: UIViewRepresentable {
}
func makeCoordinator() -> Coordinator {
- Coordinator()
+ Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote")
+ let camera = view.subviews.first(where: { $0.tag == 0 })!
+ let screen = view.subviews.first(where: { $0.tag == 1 })!
+ let screenVideo = call.peerMediaSources.screenVideo
+ if screenVideo && screen.alpha == 0 {
+ screen.alpha = 1
+ addSubviewAndResize(screen, camera, into: view)
+ } else if !screenVideo && screen.alpha == 1 {
+ screen.alpha = 0
+ addSubviewAndResize(camera, screen, into: view)
+ }
+ (view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
+ (view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
+
+ camera.alpha = call.peerMediaSources.camera ? 1 : 0
+ screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
+
DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed)
+ } else if pipShown {
+ context.coordinator.relayout()
}
}
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
+ var cameraRenderer: RTCMTLVideoView?
+ var screenRenderer: RTCMTLVideoView?
+ var client: WebRTCClient
var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in }
-
+ var relayout: () -> Void = {}
+
+ required init(_ client: WebRTCClient) {
+ self.client = client
+ }
+
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true)
}
@@ -127,11 +180,20 @@ struct CallViewRemote: UIViewRepresentable {
}
deinit {
+ // TODO: deinit is not called when changing call type from audio to video and back,
+ // which causes many renderers can be created and added to stream (if enabling/disabling
+ // video while not yet connected in outgoing call)
pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil
pipController?.delegate = nil
pipController = nil
+ if let cameraRenderer {
+ client.removeRemoteCameraRenderer(cameraRenderer)
+ }
+ if let screenRenderer {
+ client.removeRemoteScreenRenderer(screenRenderer)
+ }
}
}
@@ -148,51 +210,109 @@ struct CallViewRemote: UIViewRepresentable {
struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient
- var activeCall: Binding
var localRendererAspectRatio: Binding
@State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool
- init(client: WebRTCClient, activeCall: Binding, localRendererAspectRatio: Binding, pipShown: Binding) {
+ init(client: WebRTCClient, localRendererAspectRatio: Binding, pipShown: Binding) {
self.client = client
- self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
- if let call = activeCall.wrappedValue {
- let localRenderer = RTCEAGLVideoView(frame: .zero)
- client.addLocalRenderer(call, localRenderer)
- client.startCaptureLocalVideo(call)
- addSubviewAndResize(localRenderer, into: view)
- DispatchQueue.main.async {
- pipStateChanged = { shown in
- localRenderer.isHidden = shown
- }
+ let localRenderer = RTCEAGLVideoView(frame: .zero)
+ context.coordinator.renderer = localRenderer
+ client.addLocalRenderer(localRenderer)
+ addSubviewAndResize(localRenderer, nil, into: view)
+ DispatchQueue.main.async {
+ pipStateChanged = { shown in
+ localRenderer.isHidden = shown
}
}
return view
}
+ func makeCoordinator() -> Coordinator {
+ Coordinator(client)
+ }
+
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown)
}
+
+ // MARK: - Coordinator
+ class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
+ var renderer: RTCEAGLVideoView?
+ var client: WebRTCClient
+
+ required init(_ client: WebRTCClient) {
+ self.client = client
+ }
+
+ deinit {
+ if let renderer {
+ client.removeLocalRenderer(renderer)
+ }
+ }
+ }
}
-private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
- containerView.addSubview(view)
- view.translatesAutoresizingMaskIntoConstraints = false
- containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
- options: [],
- metrics: nil,
- views: ["view": view]))
+private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) {
+ if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) {
+ // Nothing to do, elements on their places
+ return
+ }
+ containerView.removeConstraints(containerView.constraints)
+ containerView.subviews.forEach { sub in sub.removeFromSuperview()}
- containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
+ containerView.addSubview(fullscreen)
+ fullscreen.translatesAutoresizingMaskIntoConstraints = false
+ fullscreen.layer.cornerRadius = 0
+ fullscreen.layer.masksToBounds = false
+
+ if let end {
+ containerView.addSubview(end)
+ end.translatesAutoresizingMaskIntoConstraints = false
+ end.layer.cornerRadius = pip ? 8 : 10
+ end.layer.masksToBounds = true
+ }
+
+ let constraintFullscreenV = NSLayoutConstraint.constraints(
+ withVisualFormat: "V:|[fullscreen]|",
options: [],
metrics: nil,
- views: ["view": view]))
+ views: ["fullscreen": fullscreen]
+ )
+ let constraintFullscreenH = NSLayoutConstraint.constraints(
+ withVisualFormat: "H:|[fullscreen]|",
+ options: [],
+ metrics: nil,
+ views: ["fullscreen": fullscreen]
+ )
+
+ containerView.addConstraints(constraintFullscreenV)
+ containerView.addConstraints(constraintFullscreenH)
+
+ if let end {
+ let constraintEndWidth = NSLayoutConstraint(
+ item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
+ )
+ let constraintEndHeight = NSLayoutConstraint(
+ item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
+ )
+ let constraintEndX = NSLayoutConstraint(
+ item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
+ )
+ let constraintEndY = NSLayoutConstraint(
+ item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
+ )
+ containerView.addConstraint(constraintEndWidth)
+ containerView.addConstraint(constraintEndHeight)
+ containerView.addConstraint(constraintEndX)
+ containerView.addConstraint(constraintEndY)
+ }
containerView.layoutIfNeeded()
}
diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift
index 4960281d72..5479a9fada 100644
--- a/apps/ios/Shared/Views/Call/IncomingCallView.swift
+++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift
@@ -38,6 +38,7 @@ struct IncomingCallView: View {
}
HStack {
ProfilePreview(profileOf: invitation.contact, color: .white)
+ .padding(.vertical, 6)
Spacer()
callButton("Reject", "phone.down.fill", .red) {
diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift
index 333dc082d5..ef9135761c 100644
--- a/apps/ios/Shared/Views/Call/WebRTC.swift
+++ b/apps/ios/Shared/Views/Call/WebRTC.swift
@@ -18,49 +18,49 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection
var contact: Contact
- var callkitUUID: UUID?
- var localMedia: CallMediaType
+ var callUUID: String?
+ var initialCallType: CallMediaType
+ @Published var localMediaSources: CallMediaSources
@Published var callState: CallState
@Published var localCapabilities: CallCapabilities?
- @Published var peerMedia: CallMediaType?
+ @Published var peerMediaSources: CallMediaSources = CallMediaSources()
@Published var sharedKey: String?
- @Published var audioEnabled = true
@Published var speakerEnabled = false
- @Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init(
direction: CallDirection,
contact: Contact,
- callkitUUID: UUID?,
+ callUUID: String?,
callState: CallState,
- localMedia: CallMediaType,
+ initialCallType: CallMediaType,
sharedKey: String? = nil
) {
self.direction = direction
self.contact = contact
- self.callkitUUID = callkitUUID
+ self.callUUID = callUUID
self.callState = callState
- self.localMedia = localMedia
+ self.initialCallType = initialCallType
self.sharedKey = sharedKey
- self.videoEnabled = localMedia == .video
+ self.localMediaSources = CallMediaSources(
+ mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
+ camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
}
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
- var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
+ private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey {
get {
switch callState {
case .waitCapabilities: return ""
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
- default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
+ default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
}
}
}
- var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
- var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
+ var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } }
}
enum CallDirection {
@@ -105,18 +105,28 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
var command: WCallCommand?
}
+struct CallMediaSources: Equatable, Codable {
+ var mic: Bool = false
+ var camera: Bool = false
+ var screenAudio: Bool = false
+ var screenVideo: Bool = false
+
+ var hasVideo: Bool { get { camera || screenVideo } }
+}
+
enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities(media: CallMediaType)
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 media(media: CallMediaType, enable: Bool)
+ case media(source: CallMediaSource, enable: Bool)
case end
enum CodingKeys: String, CodingKey {
case type
case media
+ case source
case aesKey
case offer
case answer
@@ -167,9 +177,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case let .ice(iceCandidates):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
- case let .media(media, enable):
+ case let .media(source, enable):
try container.encode("media", forKey: .type)
- try container.encode(media, forKey: .media)
+ try container.encode(source, forKey: .media)
try container.encode(enable, forKey: .enable)
case .end:
try container.encode("end", forKey: .type)
@@ -205,9 +215,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
case "media":
- let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
- self = .media(media: media, enable: enable)
+ self = .media(source: source, enable: enable)
case "end":
self = .end
default:
@@ -224,6 +234,7 @@ enum WCallResponse: Equatable, Decodable {
case ice(iceCandidates: String)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
+ case peerMedia(source: CallMediaSource, enabled: Bool)
case ended
case ok
case error(message: String)
@@ -238,6 +249,8 @@ enum WCallResponse: Equatable, Decodable {
case state
case connectionInfo
case message
+ case source
+ case enabled
}
var respType: String {
@@ -249,6 +262,7 @@ enum WCallResponse: Equatable, Decodable {
case .ice: return "ice"
case .connection: return "connection"
case .connected: return "connected"
+ case .peerMedia: return "peerMedia"
case .ended: return "ended"
case .ok: return "ok"
case .error: return "error"
@@ -283,6 +297,10 @@ enum WCallResponse: Equatable, Decodable {
case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: connectionInfo)
+ case "peerMedia":
+ let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
+ let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
+ self = .peerMedia(source: source, enabled: enabled)
case "ended":
self = .ended
case "ok":
@@ -324,6 +342,10 @@ extension WCallResponse: Encodable {
case let .connected(connectionInfo):
try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo)
+ case let .peerMedia(source, enabled):
+ try container.encode("peerMedia", forKey: .type)
+ try container.encode(source, forKey: .source)
+ try container.encode(enabled, forKey: .enabled)
case .ended:
try container.encode("ended", forKey: .type)
case .ok:
@@ -376,7 +398,7 @@ actor WebRTCCommandProcessor {
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
- default: client.activeCall.wrappedValue != nil
+ default: client.activeCall != nil
}
}
}
diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift
index 79e0dea16c..db7910836e 100644
--- a/apps/ios/Shared/Views/Call/WebRTCClient.swift
+++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift
@@ -23,15 +23,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
struct Call {
var connection: RTCPeerConnection
var iceCandidates: IceCandidates
- var localMedia: CallMediaType
var localCamera: RTCVideoCapturer?
- var localVideoSource: RTCVideoSource?
- var localStream: RTCVideoTrack?
- var remoteStream: RTCVideoTrack?
- var device: AVCaptureDevice.Position = .front
+ var localAudioTrack: RTCAudioTrack?
+ var localVideoTrack: RTCVideoTrack?
+ var remoteAudioTrack: RTCAudioTrack?
+ var remoteVideoTrack: RTCVideoTrack?
+ var remoteScreenAudioTrack: RTCAudioTrack?
+ var remoteScreenVideoTrack: RTCVideoTrack?
+ var device: AVCaptureDevice.Position
var aesKey: String?
var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor?
+ var peerHasOldVersion: Bool
+ }
+
+ struct NotConnectedCall {
+ var audioTrack: RTCAudioTrack?
+ var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
+ var device: AVCaptureDevice.Position = .front
}
actor IceCandidates {
@@ -51,17 +60,20 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
private var sendCallResponse: (WVAPIMessage) async -> Void
- var activeCall: Binding
+ var activeCall: Call?
+ var notConnectedCall: NotConnectedCall?
private var localRendererAspectRatio: Binding
+ var cameraRenderers: [RTCVideoRenderer] = []
+ var screenRenderers: [RTCVideoRenderer] = []
+
@available(*, unavailable)
override init() {
fatalError("Unimplemented")
}
- required init(_ activeCall: Binding, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding) {
+ required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding) {
self.sendCallResponse = sendCallResponse
- self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
rtcAudioSession.useManualAudio = CallController.useCallKit()
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
@@ -78,39 +90,45 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self
- createAudioSender(connection)
- var localStream: RTCVideoTrack? = nil
- var remoteStream: RTCVideoTrack? = nil
+ let device = notConnectedCall?.device ?? .front
var localCamera: RTCVideoCapturer? = nil
- var localVideoSource: RTCVideoSource? = nil
- if mediaType == .video {
- (localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
+ var localAudioTrack: RTCAudioTrack? = nil
+ var localVideoTrack: RTCVideoTrack? = nil
+ if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack {
+ (localCamera, localVideoTrack) = localCameraAndTrack
+ } else if notConnectedCall == nil && mediaType == .video {
+ (localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
}
+ if let audioTrack = notConnectedCall?.audioTrack {
+ localAudioTrack = audioTrack
+ } else if notConnectedCall == nil {
+ localAudioTrack = createAudioTrack()
+ }
+ notConnectedCall?.localCameraAndTrack = nil
+ notConnectedCall?.audioTrack = nil
+
var frameEncryptor: RTCFrameEncryptor? = nil
var frameDecryptor: RTCFrameDecryptor? = nil
if aesKey != nil {
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
encryptor.delegate = self
frameEncryptor = encryptor
- connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
decryptor.delegate = self
frameDecryptor = decryptor
- // Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
- // connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
}
return Call(
connection: connection,
iceCandidates: IceCandidates(),
- localMedia: mediaType,
localCamera: localCamera,
- localVideoSource: localVideoSource,
- localStream: localStream,
- remoteStream: remoteStream,
+ localAudioTrack: localAudioTrack,
+ localVideoTrack: localVideoTrack,
+ device: device,
aesKey: aesKey,
frameEncryptor: frameEncryptor,
- frameDecryptor: frameDecryptor
+ frameDecryptor: frameDecryptor,
+ peerHasOldVersion: false
)
}
@@ -151,18 +169,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func sendCallCommand(command: WCallCommand) async {
var resp: WCallResponse? = nil
- let pc = activeCall.wrappedValue?.connection
+ let pc = activeCall?.connection
switch command {
- case .capabilities:
+ case let .capabilities(media): // outgoing
+ let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
+ ? createVideoTrackAndStartCapture(.front)
+ : nil
+ notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
- case let .start(media: media, aesKey, iceServers, relay):
+ case let .start(media: media, aesKey, iceServers, relay): // incoming
logger.debug("starting incoming call - create webrtc session")
- if activeCall.wrappedValue != nil { endCall() }
+ if activeCall != nil { endCall() }
let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
- activeCall.wrappedValue = call
+ activeCall = call
+ setupLocalTracks(true, call)
let (offer, error) = await call.connection.offer()
if let offer = offer {
+ setupEncryptionForLocalTracks(call)
resp = .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
@@ -172,18 +196,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else {
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
}
- case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
- if activeCall.wrappedValue != nil {
+ case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing
+ if activeCall != nil {
resp = .error(message: "accept: call already started")
} else if !WebRTCClient.enableEncryption && aesKey != nil {
resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
- activeCall.wrappedValue = call
+ activeCall = call
let pc = call.connection
if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
+ setupLocalTracks(false, call)
+ setupEncryptionForLocalTracks(call)
+ pc.transceivers.forEach { transceiver in
+ transceiver.setDirection(.sendRecv, error: nil)
+ }
+ await adaptToOldVersion(pc.transceivers.count <= 2)
let (answer, error) = await pc.answer()
if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates)
@@ -200,7 +230,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
}
- case let .answer(answer, iceCandidates):
+ case let .answer(answer, iceCandidates): // incoming
if pc == nil {
resp = .error(message: "answer: call not started")
} else if pc?.localDescription == nil {
@@ -212,6 +242,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
let type = answer.type, let sdp = answer.sdp,
let pc = pc {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
+ var currentDirection: RTCRtpTransceiverDirection = .sendOnly
+ pc.transceivers[2].currentDirection(¤tDirection)
+ await adaptToOldVersion(currentDirection == .sendOnly)
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
} else {
@@ -226,13 +259,11 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else {
resp = .error(message: "ice: call not started")
}
- case let .media(media, enable):
- if activeCall.wrappedValue == nil {
+ case let .media(source, enable):
+ if activeCall == nil {
resp = .error(message: "media: call not started")
- } else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
- resp = .error(message: "media: no video")
} else {
- enableMedia(media, enable)
+ await enableMedia(source, enable)
resp = .ok
}
case .end:
@@ -247,7 +278,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
- let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
+ let candidates = await activeCall?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates
}
@@ -255,7 +286,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func waitForMoreIceCandidates() {
Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
- let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
+ let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates)
@@ -272,25 +303,202 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
)
}
- func enableMedia(_ media: CallMediaType, _ enable: Bool) {
- logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
- media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
+ func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) {
+ // logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)")
+ Task {
+ var lastBytesReceived: Int64 = 0
+ // muted initially
+ var mutedSeconds = 4
+ while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
+ let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
+ let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
+ if let stat {
+ //logger.debug("Stat \(stat.debugDescription)")
+ let bytes = stat.values["bytesReceived"] as! Int64
+ if bytes <= lastBytesReceived {
+ mutedSeconds += 1
+ if mutedSeconds == 3 {
+ await MainActor.run {
+ self.onMediaMuteUnmute(transceiver.mid, true)
+ }
+ }
+ } else {
+ if mutedSeconds >= 3 {
+ await MainActor.run {
+ self.onMediaMuteUnmute(transceiver.mid, false)
+ }
+ }
+ lastBytesReceived = bytes
+ mutedSeconds = 0
+ }
+ }
+ try? await Task.sleep(nanoseconds: 1000_000000)
+ }
+ }
}
- func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
- activeCall.localStream?.add(renderer)
+ @MainActor
+ func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) {
+ guard let activeCall = ChatModel.shared.activeCall else { return }
+ let source = mediaSourceFromTransceiverMid(transceiverMid)
+ logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
+ if source == .mic && activeCall.peerMediaSources.mic == mute {
+ activeCall.peerMediaSources.mic = !mute
+ } else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
+ activeCall.peerMediaSources.camera = !mute
+ } else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
+ activeCall.peerMediaSources.screenAudio = !mute
+ } else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
+ activeCall.peerMediaSources.screenVideo = !mute
+ }
+ }
+
+ @MainActor
+ func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
+ logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
+ source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
+ }
+
+ @MainActor
+ func adaptToOldVersion(_ peerHasOldVersion: Bool) {
+ activeCall?.peerHasOldVersion = peerHasOldVersion
+ if peerHasOldVersion {
+ logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
+ onMediaMuteUnmute("0", false)
+ if activeCall?.remoteVideoTrack != nil {
+ onMediaMuteUnmute("1", false)
+ }
+ if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
+ logger.debug("Stopping video track for the old version")
+ activeCall?.connection.senders[1].track = nil
+ ChatModel.shared.activeCall?.localMediaSources.camera = false
+ (activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
+ activeCall?.localCamera = nil
+ activeCall?.localVideoTrack = nil
+ }
+ }
+ }
+
+ func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
+ if let activeCall {
+ if let track = activeCall.localVideoTrack {
+ track.add(renderer)
+ }
+ } else if let notConnectedCall {
+ if let track = notConnectedCall.localCameraAndTrack?.1 {
+ track.add(renderer)
+ }
+ }
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
renderer.delegate = self
}
+ func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
+ if let activeCall {
+ if let track = activeCall.localVideoTrack {
+ track.remove(renderer)
+ }
+ } else if let notConnectedCall {
+ if let track = notConnectedCall.localCameraAndTrack?.1 {
+ track.remove(renderer)
+ }
+ }
+ renderer.delegate = nil
+ }
+
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
guard size.height > 0 else { return }
localRendererAspectRatio.wrappedValue = size.width / size.height
}
+ func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
+ let pc = call.connection
+ let transceivers = call.connection.transceivers
+ let audioTrack = call.localAudioTrack
+ let videoTrack = call.localVideoTrack
+
+ if incomingCall {
+ let micCameraInit = RTCRtpTransceiverInit()
+ // streamIds required for old versions which adds tracks from stream, not from track property
+ micCameraInit.streamIds = ["micCamera"]
+
+ let screenAudioVideoInit = RTCRtpTransceiverInit()
+ screenAudioVideoInit.streamIds = ["screenAudioVideo"]
+
+ // incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
+ // mid = 0, mic
+ if let audioTrack {
+ pc.addTransceiver(with: audioTrack, init: micCameraInit)
+ } else {
+ pc.addTransceiver(of: .audio, init: micCameraInit)
+ }
+ // mid = 1, camera
+ if let videoTrack {
+ pc.addTransceiver(with: videoTrack, init: micCameraInit)
+ } else {
+ pc.addTransceiver(of: .video, init: micCameraInit)
+ }
+ // mid = 2, screenAudio
+ pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
+ // mid = 3, screenVideo
+ pc.addTransceiver(of: .video, init: screenAudioVideoInit)
+ } else {
+ // new version
+ if transceivers.count > 2 {
+ // Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
+ transceivers
+ .first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
+ .sender.track = audioTrack
+ transceivers
+ .first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
+ .sender.track = videoTrack
+ } else {
+ // old version, only two transceivers
+ if let audioTrack {
+ pc.add(audioTrack, streamIds: ["micCamera"])
+ } else {
+ // it's important to have any track in order to be able to turn it on again (currently it's off)
+ let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
+ sender?.track = nil
+ }
+ if let videoTrack {
+ pc.add(videoTrack, streamIds: ["micCamera"])
+ } else {
+ // it's important to have any track in order to be able to turn it on again (currently it's off)
+ let localVideoSource = WebRTCClient.factory.videoSource()
+ let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
+ let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
+ sender?.track = nil
+ }
+ }
+ }
+ }
+
+ func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
+ switch mid {
+ case "0":
+ return .mic
+ case "1":
+ return .camera
+ case "2":
+ return .screenAudio
+ case "3":
+ return .screenVideo
+ default:
+ return .unknown
+ }
+ }
+
+ // Should be called after local description set
+ func setupEncryptionForLocalTracks(_ call: Call) {
+ if let encryptor = call.frameEncryptor {
+ call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
+ }
+ }
+
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
guard encrypted.count > 0 else { return nil }
- if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
+ if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
let isKeyFrame = encrypted[0] & 1 == 0
@@ -304,7 +512,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
guard unencrypted.count > 0 else { return nil }
- if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
+ if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0
@@ -327,18 +535,42 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
- func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
- activeCall.remoteStream?.add(renderer)
+ func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteVideoTrack != nil {
+ activeCall?.remoteVideoTrack?.add(renderer)
+ } else {
+ cameraRenderers.append(renderer)
+ }
}
- func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
- activeCall.remoteStream?.remove(renderer)
+ func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteVideoTrack != nil {
+ activeCall?.remoteVideoTrack?.remove(renderer)
+ } else {
+ cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
+ }
}
- func startCaptureLocalVideo(_ activeCall: Call) {
+ func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteScreenVideoTrack != nil {
+ activeCall?.remoteScreenVideoTrack?.add(renderer)
+ } else {
+ screenRenderers.append(renderer)
+ }
+ }
+
+ func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteScreenVideoTrack != nil {
+ activeCall?.remoteScreenVideoTrack?.remove(renderer)
+ } else {
+ screenRenderers.removeAll(where: { $0.isEqual(renderer) })
+ }
+ }
+
+ func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
#if targetEnvironment(simulator)
guard
- let capturer = activeCall.localCamera as? RTCFileVideoCapturer
+ let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer
else {
logger.error("Unable to work with a file capturer")
return
@@ -348,10 +580,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
#else
guard
- let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
- let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
+ let capturer = capturer as? RTCCameraVideoCapturer,
+ let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device })
else {
- logger.error("Unable to find a camera")
+ logger.error("Unable to find a camera or local track")
return
}
@@ -377,19 +609,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif
}
- private func createAudioSender(_ connection: RTCPeerConnection) {
- let streamId = "stream"
- let audioTrack = createAudioTrack()
- connection.add(audioTrack, streamIds: [streamId])
- }
-
- private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
- let streamId = "stream"
- let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
- connection.add(localVideoTrack, streamIds: [streamId])
- return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
- }
-
private func createAudioTrack() -> RTCAudioTrack {
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
@@ -397,7 +616,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
return audioTrack
}
- private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
+ private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) {
let localVideoSource = WebRTCClient.factory.videoSource()
#if targetEnvironment(simulator)
@@ -407,7 +626,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
- return (localVideoTrack, localCamera, localVideoSource)
+ startCaptureLocalVideo(device, localCamera)
+ return (localCamera, localVideoTrack)
}
func endCall() {
@@ -420,15 +640,16 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
private func _endCall() {
- guard let call = activeCall.wrappedValue else { return }
+ (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
+ guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call")
- activeCall.wrappedValue = nil
- (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
call.connection.close()
call.connection.delegate = nil
call.frameEncryptor?.delegate = nil
call.frameDecryptor?.delegate = nil
+ (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
audioSessionToDefaults()
+ activeCall = nil
}
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
@@ -437,7 +658,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
await action()
- } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
+ } while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete
}
}
@@ -498,11 +719,40 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
logger.debug("Connection should negotiate")
}
+ func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
+ if let track = transceiver.receiver.track {
+ DispatchQueue.main.async {
+ // Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
+// if let decryptor = self.activeCall?.frameDecryptor {
+// transceiver.receiver.setRtcFrameDecryptor(decryptor)
+// }
+ let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
+ switch source {
+ case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
+ case .camera:
+ self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
+ self.cameraRenderers.forEach({ renderer in
+ self.activeCall?.remoteVideoTrack?.add(renderer)
+ })
+ self.cameraRenderers.removeAll()
+ case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
+ case .screenVideo:
+ self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
+ self.screenRenderers.forEach({ renderer in
+ self.activeCall?.remoteScreenVideoTrack?.add(renderer)
+ })
+ self.screenRenderers.removeAll()
+ case .unknown: ()
+ }
+ }
+ self.setupMuteUnmuteListener(transceiver, track)
+ }
+ }
+
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
- guard let call = activeCall.wrappedValue,
- let connectionStateString = newState.toString(),
+ guard let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString()
@@ -523,18 +773,14 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
switch newState {
case .checking:
- if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
+ if let frameDecryptor = activeCall?.frameDecryptor {
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
}
- let enableSpeaker: Bool
- switch call.localMedia {
- case .video: enableSpeaker = true
- default: enableSpeaker = false
- }
+ let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall()
- default: do {}
+ default: ()
}
}
}
@@ -546,7 +792,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task {
- await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
+ await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil))
}
}
@@ -601,11 +847,42 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
}
extension WebRTCClient {
- func setAudioEnabled(_ enabled: Bool) {
- setTrackEnabled(RTCAudioTrack.self, enabled)
+ static func isAuthorized(for type: AVMediaType) async -> Bool {
+ let status = AVCaptureDevice.authorizationStatus(for: type)
+ var isAuthorized = status == .authorized
+ if status == .notDetermined {
+ isAuthorized = await AVCaptureDevice.requestAccess(for: type)
+ }
+ return isAuthorized
}
- func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
+ static func showUnauthorizedAlert(for type: AVMediaType) {
+ if type == .audio {
+ AlertManager.shared.showAlert(Alert(
+ title: Text("No permission to record speech"),
+ message: Text("To record speech please grant permission to use Microphone."),
+ primaryButton: .default(Text("Open Settings")) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
+ }
+ },
+ secondaryButton: .cancel()
+ ))
+ } else if type == .video {
+ AlertManager.shared.showAlert(Alert(
+ title: Text("No permission to record video"),
+ message: Text("To record video please grant permission to use Camera."),
+ primaryButton: .default(Text("Open Settings")) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
+ }
+ },
+ secondaryButton: .cancel()
+ ))
+ }
+ }
+
+ func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
audioQueue.async { [weak self] in
guard let self = self else { return }
@@ -618,7 +895,7 @@ extension WebRTCClient {
if enabled {
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
- if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
+ if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
try self.rtcAudioSession.setPreferredInput(preferred)
} else {
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
@@ -628,7 +905,7 @@ extension WebRTCClient {
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.overrideOutputAudioPort(.none)
}
- if hasExternalAudioDevice {
+ if hasExternalAudioDevice && !skipExternalDevice {
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
}
try self.rtcAudioSession.setActive(true)
@@ -659,25 +936,59 @@ extension WebRTCClient {
}
}
- func setVideoEnabled(_ enabled: Bool) {
- setTrackEnabled(RTCVideoTrack.self, enabled)
+ @MainActor
+ func setAudioEnabled(_ enabled: Bool) {
+ if activeCall != nil {
+ activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
+ activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
+ } else if notConnectedCall != nil {
+ notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
+ }
+ ChatModel.shared.activeCall?.localMediaSources.mic = enabled
+ }
+
+ @MainActor
+ func setCameraEnabled(_ enabled: Bool) {
+ if let call = activeCall {
+ if enabled {
+ if call.localVideoTrack == nil {
+ let device = activeCall?.device ?? notConnectedCall?.device ?? .front
+ let (camera, track) = createVideoTrackAndStartCapture(device)
+ activeCall?.localCamera = camera
+ activeCall?.localVideoTrack = track
+ }
+ } else {
+ (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
+ activeCall?.localCamera = nil
+ activeCall?.localVideoTrack = nil
+ }
+ call.connection.transceivers
+ .first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
+ .sender.track = activeCall?.localVideoTrack
+ ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
+ } else if let call = notConnectedCall {
+ if enabled {
+ let device = activeCall?.device ?? notConnectedCall?.device ?? .front
+ notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
+ } else {
+ (call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
+ notConnectedCall?.localCameraAndTrack = nil
+ }
+ ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
+ }
}
func flipCamera() {
- switch activeCall.wrappedValue?.device {
- case .front: activeCall.wrappedValue?.device = .back
- case .back: activeCall.wrappedValue?.device = .front
- default: ()
+ let device = activeCall?.device ?? notConnectedCall?.device
+ if activeCall != nil {
+ activeCall?.device = device == .front ? .back : .front
+ } else {
+ notConnectedCall?.device = device == .front ? .back : .front
}
- if let call = activeCall.wrappedValue {
- startCaptureLocalVideo(call)
- }
- }
-
- private func setTrackEnabled(_ type: T.Type, _ enabled: Bool) {
- activeCall.wrappedValue?.connection.transceivers
- .compactMap { $0.sender.track as? T }
- .forEach { $0.isEnabled = enabled }
+ startCaptureLocalVideo(
+ activeCall?.device ?? notConnectedCall?.device,
+ (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index 8c9112a858..62a41c504a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -45,7 +45,7 @@ struct ChatInfoToolbar: View {
}
private var contactVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index ea3b04c2ff..8194c8fe6f 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -7,7 +7,7 @@
//
import SwiftUI
-import SimpleXChat
+@preconcurrency import SimpleXChat
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack {
@@ -96,6 +96,8 @@ struct ChatInfoView: View {
@ObservedObject var chat: Chat
@State var contact: Contact
@State var localAlias: String
+ @State var featuresAllowed: ContactFeaturesAllowed
+ @State var currentFeaturesAllowed: ContactFeaturesAllowed
var onSearch: () -> Void
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@@ -107,6 +109,7 @@ struct ChatInfoView: View {
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
+ @State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum ChatInfoViewAlert: Identifiable {
@@ -135,50 +138,50 @@ struct ChatInfoView: View {
var body: some View {
NavigationView {
- List {
- contactInfoHeader()
- .listRowBackground(Color.clear)
- .contentShape(Rectangle())
- .onTapGesture {
- aliasTextFieldFocused = false
- }
-
- Group {
+ ZStack {
+ List {
+ contactInfoHeader()
+ .listRowBackground(Color.clear)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ aliasTextFieldFocused = false
+ }
+
localAliasTextEdit()
- }
- .listRowBackground(Color.clear)
- .listRowSeparator(.hidden)
- .padding(.bottom, 18)
-
- GeometryReader { g in
- HStack(alignment: .center, spacing: 8) {
- let buttonWidth = g.size.width / 4
- searchButton(width: buttonWidth)
- AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
- VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
- muteButton(width: buttonWidth)
- }
- }
- .padding(.trailing)
- .frame(maxWidth: .infinity)
- .frame(height: infoViewActionButtonHeight)
- .listRowBackground(Color.clear)
- .listRowSeparator(.hidden)
- .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
-
- if let customUserProfile = customUserProfile {
- Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
- HStack {
- Text("Your random profile")
- Spacer()
- Text(customUserProfile.chatViewName)
- .foregroundStyle(.indigo)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .padding(.bottom, 18)
+
+ GeometryReader { g in
+ HStack(alignment: .center, spacing: 8) {
+ let buttonWidth = g.size.width / 4
+ searchButton(width: buttonWidth)
+ AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ if let nextNtfMode = chat.chatInfo.nextNtfMode {
+ muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
+ }
}
}
- }
-
- Section {
- Group {
+ .padding(.trailing)
+ .frame(maxWidth: .infinity)
+ .frame(height: infoViewActionButtonHeight)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
+
+ if let customUserProfile = customUserProfile {
+ Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Text("Your random profile")
+ Spacer()
+ Text(customUserProfile.chatViewName)
+ .foregroundStyle(.indigo)
+ }
+ }
+ }
+
+ Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
@@ -189,97 +192,109 @@ struct ChatInfoView: View {
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
+
+ NavigationLink {
+ ChatWallpaperEditorSheet(chat: chat)
+ } label: {
+ Label("Chat theme", systemImage: "photo")
+ }
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
}
.disabled(!contact.ready || !contact.active)
- NavigationLink {
- ChatWallpaperEditorSheet(chat: chat)
- } label: {
- Label("Chat theme", systemImage: "photo")
- }
- // } else if developerTools {
- // synchronizeConnectionButtonForce()
- // }
- }
- .disabled(!contact.ready || !contact.active)
-
- if let conn = contact.activeConn {
+
Section {
- infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
- }
- }
-
- if let contactLink = contact.contactLink {
- Section {
- SimpleXLinkQRCode(uri: contactLink)
- Button {
- showShareSheet(items: [simplexChatLink(contactLink)])
- } label: {
- Label("Share address", systemImage: "square.and.arrow.up")
- }
- } header: {
- Text("Address")
- .foregroundColor(theme.colors.secondary)
+ ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
- Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
- .foregroundColor(theme.colors.secondary)
+ Text("Delete chat messages from your device.")
}
- }
-
- if contact.ready && contact.active {
- Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
- networkStatusRow()
- .onTapGesture {
- alert = .networkStatusAlert
+
+ if let conn = contact.activeConn {
+ Section {
+ infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
+ }
+ }
+
+ if let contactLink = contact.contactLink {
+ Section {
+ SimpleXLinkQRCode(uri: contactLink)
+ Button {
+ showShareSheet(items: [simplexChatLink(contactLink)])
+ } label: {
+ Label("Share address", systemImage: "square.and.arrow.up")
}
- if let connStats = connectionStats {
- Button("Change receiving address") {
- alert = .switchAddressAlert
- }
- .disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
- || connStats.ratchetSyncSendProhibited
- )
- if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
- Button("Abort changing address") {
- alert = .abortSwitchAddressAlert
+ } header: {
+ Text("Address")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ if contact.ready && contact.active {
+ Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
+ networkStatusRow()
+ .onTapGesture {
+ alert = .networkStatusAlert
+ }
+ if let connStats = connectionStats {
+ Button("Change receiving address") {
+ alert = .switchAddressAlert
}
.disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
+ if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
+ Button("Abort changing address") {
+ alert = .abortSwitchAddressAlert
+ }
+ .disabled(
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ || connStats.ratchetSyncSendProhibited
+ )
+ }
+ smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
+ smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
- }
-
- Section {
- clearChatButton()
- deleteContactButton()
- }
-
- if developerTools {
- Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
- infoRow("Local name", chat.chatInfo.localDisplayName)
- infoRow("Database ID", "\(chat.chatInfo.apiId)")
- Button ("Debug delivery") {
- Task {
- do {
- let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
- await MainActor.run { alert = .queueInfo(info: info) }
- } catch let e {
- logger.error("apiContactQueueInfo error: \(responseError(e))")
- let a = getErrorAlert(e, "Error")
- await MainActor.run { alert = .error(title: a.title, error: a.message) }
+
+ Section {
+ clearChatButton()
+ deleteContactButton()
+ }
+
+ if developerTools {
+ Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
+ infoRow("Local name", chat.chatInfo.localDisplayName)
+ infoRow("Database ID", "\(chat.chatInfo.apiId)")
+ Button ("Debug delivery") {
+ Task {
+ do {
+ let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
+ await MainActor.run { alert = .queueInfo(info: info) }
+ } catch let e {
+ logger.error("apiContactQueueInfo error: \(responseError(e))")
+ let a = getErrorAlert(e, "Error")
+ await MainActor.run { alert = .error(title: a.title, error: a.message) }
+ }
}
}
}
}
}
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarHidden(true)
+ .disabled(progressIndicator)
+ .opacity(progressIndicator ? 0.6 : 1)
+
+ if progressIndicator {
+ ProgressView().scaleEffect(2)
+ }
}
- .modifier(ThemedBackground(grouped: true))
- .navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear {
@@ -288,7 +303,6 @@ struct ChatInfoView: View {
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
-
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
@@ -312,7 +326,15 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
- case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
+ case .syncConnectionForceAlert:
+ return syncConnectionForceAlert({
+ Task {
+ if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
+ connectionStats = stats
+ dismiss()
+ }
+ }
+ })
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
@@ -322,11 +344,21 @@ struct ChatInfoView: View {
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
- .presentationDetents([.fraction(0.4)])
+ .presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
}
+ .onDisappear {
+ if currentFeaturesAllowed != featuresAllowed {
+ showAlert(
+ title: NSLocalizedString("Save preferences?", comment: "alert title"),
+ buttonTitle: NSLocalizedString("Save and notify contact", comment: "alert button"),
+ buttonAction: { savePreferences() },
+ cancelButton: true
+ )
+ }
+ }
}
private func contactInfoHeader() -> some View {
@@ -339,7 +371,7 @@ struct ChatInfoView: View {
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(contact.profile.displayName)
.font(.largeTitle)
)
@@ -402,13 +434,13 @@ struct ChatInfoView: View {
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
- private func muteButton(width: CGFloat) -> some View {
- InfoViewButton(
- image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
- title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: false))",
width: width
) {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!contact.ready || !contact.active)
}
@@ -447,8 +479,9 @@ struct ChatInfoView: View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
- featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
- currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
+ featuresAllowed: $featuresAllowed,
+ currentFeaturesAllowed: $currentFeaturesAllowed,
+ savePreferences: savePreferences
)
.navigationBarTitle("Contact preferences")
.modifier(ThemedBackground(grouped: true))
@@ -477,10 +510,15 @@ struct ChatInfoView: View {
chatSettings.sendRcpts = sendReceipts.bool()
updateChatSettings(chat, chatSettings: chatSettings)
}
-
+
private func synchronizeConnectionButton() -> some View {
Button {
- syncContactConnection(force: false)
+ Task {
+ if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
+ connectionStats = stats
+ dismiss()
+ }
+ }
} label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange)
@@ -599,29 +637,113 @@ struct ChatInfoView: View {
}
}
- private func syncContactConnection(force: Bool) {
+ private func savePreferences() {
Task {
do {
- let stats = try apiSyncContactRatchet(contact.apiId, force)
- connectionStats = stats
- await MainActor.run {
- chatModel.updateContactConnectionStats(contact, stats)
- dismiss()
+ let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
+ if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
+ await MainActor.run {
+ contact = toContact
+ chatModel.updateContact(toContact)
+ currentFeaturesAllowed = featuresAllowed
+ }
}
- } catch let error {
- logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
- let a = getErrorAlert(error, "Error synchronizing connection")
- await MainActor.run {
- alert = .error(title: a.title, error: a.message)
+ } catch {
+ logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
+ }
+ }
+ }
+}
+
+struct ChatTTLOption: View {
+ @ObservedObject var chat: Chat
+ @Binding var progressIndicator: Bool
+ @State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0))
+ @State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
+
+ var body: some View {
+ Picker("Delete messages after", selection: $chatItemTTL) {
+ ForEach(ChatItemTTL.values) { ttl in
+ Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl))
+ }
+ let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL)
+ Text(defaultTTL.text).tag(defaultTTL)
+
+ if case .chat(let ttl) = chatItemTTL, case .seconds = ttl {
+ Text(ttl.deleteAfterText).tag(chatItemTTL)
+ }
+ }
+ .disabled(progressIndicator)
+ .frame(height: 36)
+ .onChange(of: chatItemTTL) { ttl in
+ if ttl == currentChatItemTTL { return }
+ setChatTTL(
+ ttl,
+ hasPreviousTTL: !currentChatItemTTL.neverExpires,
+ onCancel: { chatItemTTL = currentChatItemTTL }
+ ) {
+ progressIndicator = true
+ Task {
+ let m = ChatModel.shared
+ do {
+ try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
+ await loadChat(chat: chat, clearItems: true)
+ await MainActor.run {
+ progressIndicator = false
+ currentChatItemTTL = chatItemTTL
+ if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id,
+ let chat = m.getChat(chat.id) {
+ chat.chatItems = []
+ m.replaceChat(chat.id, chat)
+ }
+ }
+ }
+ catch let error {
+ logger.error("setChatTTL error \(responseError(error))")
+ await loadChat(chat: chat, clearItems: true)
+ await MainActor.run {
+ chatItemTTL = currentChatItemTTL
+ progressIndicator = false
+ }
+ }
}
}
}
+ .onAppear {
+ let sm = ChatModel.shared
+ let ttl = chat.chatInfo.ttl(sm.chatItemTTL)
+ chatItemTTL = ttl
+ currentChatItemTTL = ttl
+ }
+ }
+}
+
+func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
+ do {
+ let stats = try apiSyncContactRatchet(contact.apiId, force)
+ await MainActor.run {
+ ChatModel.shared.updateContactConnectionStats(contact, stats)
+ }
+ return stats
+ } catch let error {
+ logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
+ let a = getErrorAlert(error, "Error synchronizing connection")
+ await MainActor.run {
+ showAlert(
+ SomeAlert(
+ alert: mkAlert(title: a.title, message: a.message),
+ id: "syncContactConnection error"
+ )
+ )
+ }
+ return nil
}
}
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -629,6 +751,7 @@ struct AudioCallButton: View {
CallButton(
chat: chat,
contact: contact,
+ connectionStats: $connectionStats,
image: "phone.fill",
title: "call",
mediaType: .audio,
@@ -641,6 +764,7 @@ struct AudioCallButton: View {
struct VideoButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -648,6 +772,7 @@ struct VideoButton: View {
CallButton(
chat: chat,
contact: contact,
+ connectionStats: $connectionStats,
image: "video.fill",
title: "video",
mediaType: .video,
@@ -660,6 +785,7 @@ struct VideoButton: View {
private struct CallButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
@@ -671,12 +797,40 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
- if CallController.useCallKit() {
- CallController.shared.startCall(contact, mediaType)
- } else {
- // When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
- dismissAllSheets(animated: true) {
- CallController.shared.startCall(contact, mediaType)
+ if let connStats = connectionStats {
+ if connStats.ratchetSyncState == .ok {
+ if CallController.useCallKit() {
+ CallController.shared.startCall(contact, mediaType)
+ } else {
+ // When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
+ dismissAllSheets(animated: true) {
+ CallController.shared.startCall(contact, mediaType)
+ }
+ }
+ } else if connStats.ratchetSyncAllowed {
+ showAlert(SomeAlert(
+ alert: Alert(
+ title: Text("Fix connection?"),
+ message: Text("Connection requires encryption renegotiation."),
+ primaryButton: .default(Text("Fix")) {
+ Task {
+ if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
+ connectionStats = stats
+ }
+ }
+ },
+ secondaryButton: .cancel()
+ ),
+ id: "can't call contact, fix connection"
+ ))
+ } else {
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Can't call contact",
+ message: "Encryption renegotiation in progress."
+ ),
+ id: "can't call contact, encryption renegotiation in progress"
+ ))
}
}
} else if contact.nextSendGrpInv {
@@ -942,7 +1096,7 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
)
}
-func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
+func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
let (rcvMsgInfo, qInfo) = info
var msgInfo: String
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
@@ -975,6 +1129,33 @@ func deleteContactDialog(
}
}
+func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
+ let title = if ttl.neverExpires {
+ NSLocalizedString("Disable automatic message deletion?", comment: "alert title")
+ } else if ttl.usingDefault || hasPreviousTTL {
+ NSLocalizedString("Change automatic message deletion?", comment: "alert title")
+ } else {
+ NSLocalizedString("Enable automatic message deletion?", comment: "alert title")
+ }
+
+ let message = if ttl.neverExpires {
+ NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message")
+ } else {
+ NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message")
+ }
+
+ showAlert(title, message: message) {
+ [
+ UIAlertAction(
+ title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"),
+ style: .destructive,
+ handler: { _ in onConfirm() }
+ ),
+ UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() })
+ ]
+ }
+}
+
private func deleteContactOrConversationDialog(
_ chat: Chat,
_ contact: Contact,
@@ -1173,7 +1354,9 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
localAlias: "",
- onSearch: {}
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
+ onSearch: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
index 3b3e1b3899..0283e9c07e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
@@ -50,7 +50,7 @@ struct CICallItemView: View {
Image(systemName: "phone.connection").foregroundColor(.green)
}
- @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
+ private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
@@ -60,16 +60,16 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo {
- Button {
- if let invitation = m.callInvitations[contact.id] {
- CallController.shared.answerCall(invitation: invitation)
- logger.debug("acceptCallButton call answered")
- } else {
- AlertManager.shared.showAlertMsg(title: "Call already ended!")
- }
- } label: {
- Label("Answer call", systemImage: "phone.arrow.down.left")
- }
+ Label("Answer call", systemImage: "phone.arrow.down.left")
+ .foregroundColor(theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded {
+ if let invitation = m.callInvitations[contact.id] {
+ CallController.shared.answerCall(invitation: invitation)
+ logger.debug("acceptCallButton call answered")
+ } else {
+ AlertManager.shared.showAlertMsg(title: "Call already ended!")
+ }
+ })
} else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
index 27d8d9c2de..02be8af73b 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
@@ -11,11 +11,11 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
- @Binding var revealed: Bool
var feature: Feature
var icon: String? = nil
var iconColor: Color
@@ -106,6 +106,9 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
- CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
+ CIChatFeatureView(
+ chat: Chat.sampleData,
+ chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
+ ).environment(\.revealed, true)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
index 752f599c8d..67f7b69e2c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
@@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View {
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
- }
+ })
} else {
featurePreferenceView()
}
@@ -47,7 +47,7 @@ struct CIFeaturePreferenceView: View {
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
- + Text(" ")
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index fcb330c321..b0b404d8b5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -19,42 +19,42 @@ struct CIFileView: View {
var body: some View {
if smallViewSize != nil {
fileIndicator()
- .onTapGesture(perform: fileAction)
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
} else {
let metaReserve = edited
? " "
: " "
- Button(action: fileAction) {
- HStack(alignment: .bottom, spacing: 6) {
- fileIndicator()
- .padding(.top, 5)
- .padding(.bottom, 3)
- if let file = file {
- let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
- VStack(alignment: .leading, spacing: 2) {
- Text(file.fileName)
- .lineLimit(1)
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.onBackground)
- Text(prettyFileSize + metaReserve)
- .font(.caption)
- .lineLimit(1)
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.secondary)
- }
- } else {
- Text(metaReserve)
+ HStack(alignment: .bottom, spacing: 6) {
+ fileIndicator()
+ .padding(.top, 5)
+ .padding(.bottom, 3)
+ if let file = file {
+ let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(file.fileName)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(theme.colors.onBackground)
+ Text(prettyFileSize + metaReserve)
+ .font(.caption)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(theme.colors.secondary)
}
+ } else {
+ Text(metaReserve)
}
- .padding(.top, 4)
- .padding(.bottom, 6)
- .padding(.leading, 10)
- .padding(.trailing, 12)
}
+ .padding(.top, 4)
+ .padding(.bottom, 6)
+ .padding(.leading, 10)
+ .padding(.trailing, 12)
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive)
}
}
+ @inline(__always)
private var itemInteractive: Bool {
if let file = file {
switch (file.fileStatus) {
@@ -118,16 +118,10 @@ struct CIFileView: View {
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(rcvFileError.errorInfo)
- ))
+ showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(rcvFileError.errorInfo)
- ))
+ showFileErrorAlert(rcvFileError, temporary: true)
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
@@ -140,16 +134,10 @@ struct CIFileView: View {
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(sndFileError.errorInfo)
- ))
+ showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(sndFileError.errorInfo)
- ))
+ showFileErrorAlert(sndFileError, temporary: true)
default: break
}
}
@@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) {
}
}
+func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
+ let title: String = if temporary {
+ NSLocalizedString("Temporary file error", comment: "file error alert title")
+ } else {
+ NSLocalizedString("File error", comment: "file error alert title")
+ }
+ if let btn = err.moreInfoButton {
+ showAlert(title, message: err.errorInfo) {
+ [
+ okAlertAction,
+ UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
+ UIApplication.shared.open(contentModerationPostLink)
+ })
+ ]
+ }
+ } else {
+ showAlert(title, message: err.errorInfo)
+ }
+}
+
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
@@ -285,17 +293,18 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
index ef0fec5dfe..3fcf578875 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
@@ -44,16 +45,16 @@ struct CIGroupInvitationView: View {
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
}
} else {
(
groupInvitationText()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
}
@@ -69,7 +70,7 @@ struct CIGroupInvitationView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, theme))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -83,12 +84,12 @@ struct CIGroupInvitationView: View {
}
if action {
- v.onTapGesture {
+ v.simultaneousGesture(TapGesture().onEnded {
inProgress = true
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
- }
+ })
.disabled(inProgress)
} else {
v
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index 3966d7e258..d30369339d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
+ var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@@ -25,12 +26,14 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
- FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
+ FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
- .onTapGesture { showFullScreenImage = true }
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
+ }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
@@ -42,7 +45,7 @@ struct CIImageView: View {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
@@ -69,29 +72,17 @@ struct CIImageView: View {
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(rcvFileError.errorInfo)
- ))
+ showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(rcvFileError.errorInfo)
- ))
+ showFileErrorAlert(rcvFileError, temporary: true)
case let .sndError(sndFileError):
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(sndFileError.errorInfo)
- ))
+ showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(sndFileError.errorInfo)
- ))
+ showFileErrorAlert(sndFileError, temporary: true)
default: ()
}
}
- }
+ })
}
}
.onDisappear {
@@ -165,9 +156,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
+ .invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
- .foregroundColor(.white)
.padding(padding)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
index 18fd682646..5e9fa691de 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
@@ -7,10 +7,11 @@
//
import SwiftUI
+import SimpleXChat
struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
- var json: String
+ var json: Data?
@State private var showJSON = false
var body: some View {
@@ -23,16 +24,16 @@ struct CIInvalidJSONView: View {
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
- .onTapGesture { showJSON = true }
+ .simultaneousGesture(TapGesture().onEnded { showJSON = true })
.appSheet(isPresented: $showJSON) {
- invalidJSONView(json)
+ invalidJSONView(dataToString(json))
}
}
}
func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
- Button {
+ Button { // this is used in the sheet, Button works here
showShareSheet(items: [json])
} label: {
Image(systemName: "square.and.arrow.up")
@@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View {
struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View {
- CIInvalidJSONView(json: "{}")
+ CIInvalidJSONView(json: "{}".data(using: .utf8)!)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index 3c864ab172..f9dbaede63 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -16,20 +16,20 @@ struct CILinkView: View {
var body: some View {
VStack(alignment: .center, spacing: 6) {
- if let uiImage = UIImage(base64Encoded: linkPreview.image) {
+ if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded {
+ openBrowserAlert(uri: linkPreview.uri)
+ })
+ }
}
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
.lineLimit(3)
-// if linkPreview.description != "" {
-// Text(linkPreview.description)
-// .font(.subheadline)
-// .lineLimit(12)
-// }
Text(linkPreview.uri.absoluteString)
.font(.caption)
.lineLimit(1)
@@ -37,10 +37,32 @@ struct CILinkView: View {
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
+ .simultaneousGesture(TapGesture().onEnded {
+ openBrowserAlert(uri: linkPreview.uri)
+ })
}
}
}
+func openBrowserAlert(uri: URL) {
+ showAlert(
+ NSLocalizedString("Open link?", comment: "alert title"),
+ message: uri.absoluteString,
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default,
+ handler: { _ in }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Open", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(uri) }
+ )
+ ]}
+ )
+}
+
struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
index 463695ddb7..2898a318a9 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
@@ -20,12 +20,11 @@ struct CIMemberCreatedContactView: View {
case let .groupRcv(groupMember):
if let contactId = groupMember.memberContactId {
memberCreatedContactView(openText: "Open")
- .onTapGesture {
- dismissAllSheets(animated: true)
- DispatchQueue.main.async {
- m.chatId = "@\(contactId)"
+ .simultaneousGesture(TapGesture().onEnded {
+ ItemsModel.shared.loadOpenChat("@\(contactId)") {
+ dismissAllSheets(animated: true)
}
- }
+ })
} else {
memberCreatedContactView()
}
@@ -45,7 +44,7 @@ struct CIMemberCreatedContactView: View {
+ Text(openText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
- + Text(" ")
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index 66b810cf2f..fc73778239 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -12,11 +12,13 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var metaColor: Color
- var paleMetaColor = Color(UIColor.tertiaryLabel)
+ var paleMetaColor = Color(uiColor: .tertiaryLabel)
var showStatus = true
var showEdited = true
+ var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@@ -24,93 +26,145 @@ struct CIMetaView: View {
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
- let meta = chatItem.meta
- let ttl = chat.chatInfo.timedMessagesTTL
- let encrypted = chatItem.encryptedFile
- switch meta.itemStatus {
- case let .sndSent(sndProgress):
- switch sndProgress {
- case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
+ ZStack {
+ ciMetaText(
+ chatItem.meta,
+ chatTTL: chat.chatInfo.timedMessagesTTL,
+ encrypted: chatItem.encryptedFile,
+ color: metaColor,
+ paleColor: paleMetaColor,
+ colorMode: invertedMaterial
+ ? .invertedMaterial
+ : .normal,
+ showStatus: showStatus,
+ showEdited: showEdited,
+ showViaProxy: showSentViaProxy,
+ showTimesamp: showTimestamp
+ ).invertedForegroundStyle(enabled: invertedMaterial)
+ if invertedMaterial {
+ ciMetaText(
+ chatItem.meta,
+ chatTTL: chat.chatInfo.timedMessagesTTL,
+ encrypted: chatItem.encryptedFile,
+ colorMode: .normal,
+ onlyOverrides: true,
+ showStatus: showStatus,
+ showEdited: showEdited,
+ showViaProxy: showSentViaProxy,
+ showTimesamp: showTimestamp
+ )
}
- case let .sndRcvd(_, sndProgress):
- switch sndProgress {
- case .complete:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- }
- case .partial:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- }
- }
- default:
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
}
}
-enum SentCheckmark {
- case sent
- case rcvd1
- case rcvd2
+enum MetaColorMode {
+ // Renders provided colours
+ case normal
+ // Fully transparent meta - used for reserving space
+ case transparent
+ // Renders white on dark backgrounds and black on light ones
+ case invertedMaterial
+
+ func resolve(_ c: Color?) -> Color? {
+ switch self {
+ case .normal: c
+ case .transparent: .clear
+ case .invertedMaterial: nil
+ }
+ }
+
+ func statusSpacer(_ sent: Bool) -> Text {
+ switch self {
+ case .normal, .transparent:
+ Text(
+ sent
+ ? Image("checkmark.wide")
+ : Image(systemName: "circlebadge.fill")
+ ).foregroundColor(.clear)
+ case .invertedMaterial: textSpace.kerning(13)
+ }
+ }
}
func ciMetaText(
_ meta: CIMeta,
chatTTL: Int?,
encrypted: Bool?,
- color: Color = .clear,
+ color: Color = .clear, // we use this function to reserve space without rendering meta
+ paleColor: Color? = nil,
primaryColor: Color = .accentColor,
- transparent: Bool = false,
- sent: SentCheckmark? = nil,
+ colorMode: MetaColorMode = .normal,
+ onlyOverrides: Bool = false, // only render colors that differ from base
showStatus: Bool = true,
showEdited: Bool = true,
- showViaProxy: Bool
+ showViaProxy: Bool,
+ showTimesamp: Bool
) -> Text {
var r = Text("")
+ var space: Text? = nil
+ let appendSpace = {
+ if let sp = space {
+ r = r + sp
+ space = nil
+ }
+ }
+ let resolved = colorMode.resolve(color)
if showEdited, meta.itemEdited {
- r = r + statusIconText("pencil", color)
+ r = r + statusIconText("pencil", resolved)
}
if meta.disappearing {
- r = r + statusIconText("timer", color).font(.caption2)
+ r = r + statusIconText("timer", resolved).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
- r = r + Text(shortTimeText(ttl)).foregroundColor(color)
+ r = r + colored(Text(shortTimeText(ttl)), resolved)
}
- r = r + Text(" ")
+ space = textSpace
}
if showViaProxy, meta.sentViaProxy == true {
- r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
+ appendSpace()
+ r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
}
if showStatus {
- if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
- let t = Text(Image(systemName: icon)).font(.caption2)
- let gap = Text(" ").kerning(-1.25)
- let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
- switch sent {
- case nil: r = r + t1
- case .sent: r = r + t1 + gap
- case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
- case .rcvd2: r = r + gap + t1
+ appendSpace()
+ if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
+ let metaColor = if onlyOverrides && statusColor == color {
+ Color.clear
+ } else {
+ colorMode.resolve(statusColor)
}
- r = r + Text(" ")
+ r = r + colored(Text(image), metaColor)
} else if !meta.disappearing {
- r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
+ r = r + colorMode.statusSpacer(meta.itemStatus.sent)
}
+ space = textSpace
}
if let enc = encrypted {
- r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
+ appendSpace()
+ r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
+ space = textSpace
+ }
+ if showTimesamp {
+ appendSpace()
+ r = r + colored(meta.timestampText, resolved)
}
- r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption)
}
-private func statusIconText(_ icon: String, _ color: Color) -> Text {
- Text(Image(systemName: icon)).foregroundColor(color)
+@inline(__always)
+private func statusIconText(_ icon: String, _ color: Color?) -> Text {
+ colored(Text(Image(systemName: icon)), color)
+}
+
+// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
+@inline(__always)
+private func colored(_ t: Text, _ color: Color?) -> Text {
+ if let color {
+ t.foregroundColor(color)
+ } else {
+ t
+ }
}
struct CIMetaView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index 1f2e16448d..4e5713c263 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
+ @Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@@ -47,7 +48,7 @@ struct CIRcvDecryptionError: View {
if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
- let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
+ let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s)
}
@@ -67,38 +68,41 @@ struct CIRcvDecryptionError: View {
}
}
- @ViewBuilder private func viewBody() -> some View {
- if case let .direct(contact) = chat.chatInfo,
- let contactStats = contact.activeConn?.connectionStats {
- if contactStats.ratchetSyncAllowed {
- decryptionErrorItemFixButton(syncSupported: true) {
- alert = .syncAllowedAlert { syncContactConnection(contact) }
+ private func viewBody() -> some View {
+ Group {
+ if case let .direct(contact) = chat.chatInfo,
+ let contactStats = contact.activeConn?.connectionStats {
+ if contactStats.ratchetSyncAllowed {
+ decryptionErrorItemFixButton(syncSupported: true) {
+ alert = .syncAllowedAlert { syncContactConnection(contact) }
+ }
+ } else if !contactStats.ratchetSyncSupported {
+ decryptionErrorItemFixButton(syncSupported: false) {
+ alert = .syncNotSupportedContactAlert
+ }
+ } else {
+ basicDecryptionErrorItem()
}
- } else if !contactStats.ratchetSyncSupported {
- decryptionErrorItemFixButton(syncSupported: false) {
- alert = .syncNotSupportedContactAlert
+ } else if case let .group(groupInfo) = chat.chatInfo,
+ case let .groupRcv(groupMember) = chatItem.chatDir,
+ let mem = m.getGroupMember(groupMember.groupMemberId),
+ let memberStats = mem.wrapped.activeConn?.connectionStats {
+ if memberStats.ratchetSyncAllowed {
+ decryptionErrorItemFixButton(syncSupported: true) {
+ alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
+ }
+ } else if !memberStats.ratchetSyncSupported {
+ decryptionErrorItemFixButton(syncSupported: false) {
+ alert = .syncNotSupportedMemberAlert
+ }
+ } else {
+ basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
}
- } else if case let .group(groupInfo) = chat.chatInfo,
- case let .groupRcv(groupMember) = chatItem.chatDir,
- let mem = m.getGroupMember(groupMember.groupMemberId),
- let memberStats = mem.wrapped.activeConn?.connectionStats {
- if memberStats.ratchetSyncAllowed {
- decryptionErrorItemFixButton(syncSupported: true) {
- alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
- }
- } else if !memberStats.ratchetSyncSupported {
- decryptionErrorItemFixButton(syncSupported: false) {
- alert = .syncNotSupportedMemberAlert
- }
- } else {
- basicDecryptionErrorItem()
- }
- } else {
- basicDecryptionErrorItem()
}
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
}
private func basicDecryptionErrorItem() -> some View {
@@ -117,21 +121,20 @@ struct CIRcvDecryptionError: View {
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
+ + textSpace
+ Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
- .onTapGesture(perform: { onClick() })
+ .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -141,16 +144,15 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
- .onTapGesture(perform: { onClick() })
+ .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -159,13 +161,13 @@ struct CIRcvDecryptionError: View {
let why = Text(decryptErrorReason)
switch msgDecryptError {
case .ratchetHeader:
- message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
+ message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .tooManySkipped:
- message = Text("\(msgCount) messages skipped.") + Text("\n") + why
+ message = Text("\(msgCount) messages skipped.") + textNewLine + why
case .ratchetEarlier:
- message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
+ message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .other:
- message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
+ message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .ratchetSync:
message = Text("Encryption re-negotiation failed.")
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index 4670fc685f..eacbe9360a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -47,57 +47,57 @@ struct CIVideoView: View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
- if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
- smallVideoView(decrypted, file, preview)
- } else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
- videoView(player, decrypted, file, preview, duration)
- } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
- smallVideoViewEncrypted(file, defaultPreview)
- } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
- videoViewEncrypted(file, defaultPreview, duration)
- } else if let preview, let file {
- Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
- .onTapGesture {
- switch file.fileStatus {
- case .rcvInvitation, .rcvAborted:
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- case .rcvAccepted:
- switch file.fileProtocol {
- case .xftp:
- AlertManager.shared.showAlertMsg(
- title: "Waiting for video",
- message: "Video will be received when your contact completes uploading it."
- )
- case .smp:
- AlertManager.shared.showAlertMsg(
- title: "Waiting for video",
- message: "Video will be received when your contact is online, please wait or check later!"
- )
- case .local: ()
- }
- case .rcvTransfer: () // ?
- case .rcvComplete: () // ?
- case .rcvCancelled: () // TODO
- default: ()
- }
+ if let file, let preview {
+ if let urlDecrypted {
+ if smallView {
+ smallVideoView(urlDecrypted, file, preview)
+ } else if let player {
+ videoView(player, urlDecrypted, file, preview, duration)
}
+ } else if file.loaded {
+ if smallView {
+ smallVideoViewEncrypted(file, preview)
+ } else {
+ videoViewEncrypted(file, preview, duration)
+ }
+ } else {
+ Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
+ .simultaneousGesture(TapGesture().onEnded {
+ switch file.fileStatus {
+ case .rcvInvitation, .rcvAborted:
+ receiveFileIfValidSize(file: file, receiveFile: receiveFile)
+ case .rcvAccepted:
+ switch file.fileProtocol {
+ case .xftp:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for video",
+ message: "Video will be received when your contact completes uploading it."
+ )
+ case .smp:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for video",
+ message: "Video will be received when your contact is online, please wait or check later!"
+ )
+ case .local: ()
+ }
+ case .rcvTransfer: () // ?
+ case .rcvComplete: () // ?
+ case .rcvCancelled: () // TODO
+ default: ()
+ }
+ })
+ }
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
- if !smallView {
- Button {
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- } label: {
- playPauseIcon("play.fill")
- }
- } else if !file.showStatusIconInSmallView {
+ if !smallView || !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- }
+ })
}
}
}
@@ -151,27 +151,26 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
- }
+ })
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
- Button {
- decrypt(file: file) {
- if urlDecrypted != nil {
- videoPlaying = true
- player?.play()
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ decrypt(file: file) {
+ if urlDecrypted != nil {
+ videoPlaying = true
+ player?.play()
+ }
}
- }
- } label: {
- playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
- }
- .disabled(!canBePlayed)
+ })
+ .disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
@@ -194,29 +193,30 @@ struct CIVideoView: View {
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
- .onTapGesture {
- switch player.timeControlStatus {
- case .playing:
- player.pause()
- videoPlaying = false
- case .paused:
- if canBePlayed {
- showFullScreenPlayer = true
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded {
+ switch player.timeControlStatus {
+ case .playing:
+ player.pause()
+ videoPlaying = false
+ case .paused:
+ if canBePlayed {
+ showFullScreenPlayer = true
+ }
+ default: ()
}
- default: ()
- }
+ })
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
- Button {
- m.stopPreviousRecPlay = url
- player.play()
- } label: {
- playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
- }
- .disabled(!canBePlayed)
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ m.stopPreviousRecPlay = url
+ player.play()
+ })
+ .disabled(!canBePlayed)
}
}
fileStatusIcon()
@@ -235,7 +235,7 @@ struct CIVideoView: View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
- .onTapGesture {
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
@@ -256,7 +256,7 @@ struct CIVideoView: View {
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
- .onTapGesture {
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
@@ -292,30 +292,22 @@ struct CIVideoView: View {
.clipShape(Circle())
}
- private func durationProgress() -> some View {
- HStack {
- Text("\(durationText(videoPlaying ? progress : duration))")
- .foregroundColor(.white)
- .font(.caption)
- .padding(.vertical, 3)
- .padding(.horizontal, 6)
- .background(Color.black.opacity(0.35))
- .cornerRadius(10)
- .padding([.top, .leading], 6)
-
- if let file = chatItem.file, !videoPlaying {
- Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
- .foregroundColor(.white)
- .font(.caption)
- .padding(.vertical, 3)
- .padding(.horizontal, 6)
- .background(Color.black.opacity(0.35))
- .cornerRadius(10)
- .padding(.top, 6)
- }
+ private var fileSizeString: String {
+ if let file = chatItem.file, !videoPlaying {
+ " " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
+ } else {
+ ""
}
}
+ private func durationProgress() -> some View {
+ Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
+ .invertedForegroundStyle()
+ .font(.caption)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ }
+
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
return ZStack(alignment: .topTrailing) {
@@ -362,20 +354,14 @@ struct CIVideoView: View {
case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError):
fileIcon("xmark", 10, 13)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(sndFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError)
+ })
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(sndFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError, temporary: true)
+ })
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal):
@@ -389,20 +375,14 @@ struct CIVideoView: View {
case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(rcvFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError)
+ })
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(rcvFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError, temporary: true)
+ })
case .invalid: fileIcon("questionmark", 10, 13)
}
}
@@ -411,9 +391,9 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
+ .invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
- .foregroundColor(.white)
.padding(smallView ? 0 : padding)
}
@@ -428,10 +408,8 @@ struct CIVideoView: View {
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle()
.trim(from: 0, to: Double(progress) / Double(total))
- .stroke(
- Color(uiColor: .white),
- style: StrokeStyle(lineWidth: 2)
- )
+ .stroke(style: StrokeStyle(lineWidth: 2))
+ .invertedForegroundStyle()
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
@@ -451,7 +429,7 @@ struct CIVideoView: View {
Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: {
- Button(action: { showFullScreenPlayer = false },
+ Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
label: {
Image(systemName: "multiply")
.resizable()
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 45a20f03bd..715e606a74 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -168,20 +168,14 @@ struct VoiceMessagePlayer: View {
case .sndCancelled: playbackButton()
case let .sndError(sndFileError):
fileStatusIcon("multiply", 14)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(sndFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError)
+ })
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(sndFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError, temporary: true)
+ })
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
@@ -190,20 +184,14 @@ struct VoiceMessagePlayer: View {
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("File error"),
- message: Text(rcvFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError)
+ })
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
- .onTapGesture {
- AlertManager.shared.showAlert(Alert(
- title: Text("Temporary file error"),
- message: Text(rcvFileError.errorInfo)
- ))
- }
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError, temporary: true)
+ })
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
@@ -267,59 +255,29 @@ struct VoiceMessagePlayer: View {
}
}
- @ViewBuilder private func playbackButton() -> some View {
- if sizeMultiplier != 1 {
- switch playbackState {
- case .noPlayback:
- playPauseIcon("play.fill", theme.colors.primary)
- .onTapGesture {
- if let recordingSource = getLoadedFileSource(recordingFile) {
- startPlayback(recordingSource)
- }
- }
- case .playing:
- playPauseIcon("pause.fill", theme.colors.primary)
- .onTapGesture {
- audioPlayer?.pause()
- playbackState = .paused
- notifyStateChange()
- }
- case .paused:
- playPauseIcon("play.fill", theme.colors.primary)
- .onTapGesture {
- audioPlayer?.play()
- playbackState = .playing
- notifyStateChange()
- }
- }
- } else {
- switch playbackState {
- case .noPlayback:
- Button {
+ private func playbackButton() -> some View {
+ let icon = switch playbackState {
+ case .noPlayback: "play.fill"
+ case .playing: "pause.fill"
+ case .paused: "play.fill"
+ }
+ return playPauseIcon(icon, theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded { _ in
+ switch playbackState {
+ case .noPlayback:
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
- } label: {
- playPauseIcon("play.fill", theme.colors.primary)
- }
- case .playing:
- Button {
+ case .playing:
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
- } label: {
- playPauseIcon("pause.fill", theme.colors.primary)
- }
- case .paused:
- Button {
+ case .paused:
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
- } label: {
- playPauseIcon("play.fill", theme.colors.primary)
}
- }
- }
+ })
}
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
@@ -341,28 +299,14 @@ struct VoiceMessagePlayer: View {
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
- Group {
- if sizeMultiplier != 1 {
- playPauseIcon(icon, theme.colors.primary)
- .onTapGesture {
- Task {
- if let user = chatModel.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
- }
- }
+ playPauseIcon(icon, theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded {
+ Task {
+ if let user = chatModel.currentUser {
+ await receiveFile(user: user, fileId: recordingFile.fileId)
}
- } else {
- Button {
- Task {
- if let user = chatModel.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
- }
- }
- } label: {
- playPauseIcon(icon, theme.colors.primary)
}
- }
- }
+ })
}
func notifyStateChange() {
@@ -442,6 +386,7 @@ struct VoiceMessagePlayer: View {
}
}
+@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
@@ -458,10 +403,12 @@ class VoiceItemState {
self.playbackTime = playbackTime
}
+ @inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
+ @inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
@@ -510,10 +457,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index 64a7f29a25..f4e2a4135a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -92,12 +92,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 313ec0d419..b27d266d8a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -12,11 +12,10 @@ import SimpleXChat
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat
var chatItem: ChatItem
+ var scrollToItemId: (ChatItem.ID) -> Void
var preview: UIImage?
- @Binding var revealed: Bool
var maxWidth: CGFloat = .infinity
@State var msgWidth: CGFloat = 0
var imgWidth: CGFloat? = nil
@@ -24,14 +23,22 @@ struct FramedItemView: View {
@State private var useWhiteMetaColor: Bool = false
@State var showFullScreenImage = false
@Binding var allowMenu: Bool
- @State private var showSecrets = false
- @State private var showQuoteSecrets = false
@State private var showFullscreenGallery: Bool = false
var body: some View {
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
- if let di = chatItem.meta.itemDeleted {
+ if chatItem.isReport {
+ if chatItem.meta.itemDeleted == nil {
+ let txt = chatItem.chatDir.sent ?
+ Text("Only you and moderators see it") :
+ Text("Only sender and moderators see it")
+
+ framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic())
+ } else {
+ framedItemHeader(icon: "flag", caption: Text("archived report").italic())
+ }
+ } else if let di = chatItem.meta.itemDeleted {
switch di {
case let .moderated(_, byGroupMember):
framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic())
@@ -48,42 +55,51 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
- scrollModel.scrollToItem(id: ci.id)
+ scrollToItemId(ci.id)
}
+ } else if let id = qi.itemId {
+ scrollToItemId(id)
+ } else {
+ showQuotedItemDoesNotExistAlert()
}
- }
+ })
} else if let itemForwarded = chatItem.meta.itemForwarded {
framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true)
}
- ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView)
+ ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
- if chatItem.content.msgContent != nil {
- CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
- .padding(.horizontal, 12)
- .padding(.bottom, 6)
- .overlay(DetermineWidth())
- .accessibilityLabel("")
+ if let content = chatItem.content.msgContent {
+ CIMetaView(
+ chat: chat,
+ chatItem: chatItem,
+ metaColor: theme.colors.secondary,
+ invertedMaterial: useWhiteMetaColor
+ )
+ .padding(.horizontal, 12)
+ .padding(.bottom, 6)
+ .overlay(DetermineWidth())
+ .accessibilityLabel("")
}
}
- .background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
+ .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
- v.onTapGesture {
+ v.simultaneousGesture(TapGesture().onEnded {
AlertManager.shared.showAlert(
Alert(
title: Text(title),
message: Text(text)
)
)
- }
+ })
} else {
v
}
@@ -103,7 +119,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, _):
- CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
+ CIImageView(chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -140,6 +156,8 @@ struct FramedItemView: View {
}
case let .file(text):
ciFileView(chatItem, text)
+ case let .report(text, reason):
+ ciMsgContentView(chatItem, txtPrefix: reason.attrString)
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView(chatItem)
@@ -155,13 +173,14 @@ struct FramedItemView: View {
}
}
- @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View {
+ @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View {
let v = HStack(spacing: 6) {
if let icon = icon {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
+ .foregroundColor(iconColor ?? theme.colors.secondary)
}
caption
.font(.caption)
@@ -182,10 +201,11 @@ struct FramedItemView: View {
}
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
+ let backgroundColor = chatItemFrameContextColor(chatItem, theme)
let v = ZStack(alignment: .topTrailing) {
switch (qi.content) {
case let .image(_, image):
- if let uiImage = UIImage(base64Encoded: image) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -197,7 +217,7 @@ struct FramedItemView: View {
ciQuotedMsgView(qi)
}
case let .video(_, image, _):
- if let uiImage = UIImage(base64Encoded: image) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -223,8 +243,8 @@ struct FramedItemView: View {
// if enable this always, size of the framed voice message item will be incorrect after end of playback
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
.frame(minWidth: msgWidth, alignment: .leading)
- .background(chatItemFrameContextColor(chatItem, theme))
-
+ .background(backgroundColor)
+ .environment(\.containerBackground, UIColor(backgroundColor))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@@ -238,7 +258,7 @@ struct FramedItemView: View {
VStack(alignment: .leading, spacing: 2) {
Text(sender)
.font(.caption)
- .foregroundColor(theme.colors.secondary)
+ .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
.lineLimit(1)
ciQuotedMsgTextView(qi, lines: 2)
}
@@ -250,14 +270,12 @@ struct FramedItemView: View {
.padding(.top, 6)
.padding(.horizontal, 12)
}
-
+
+ @inline(__always)
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
- toggleSecrets(qi.formattedText, $showQuoteSecrets,
- MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets)
- .lineLimit(lines)
- .font(.subheadline)
- .padding(.bottom, 6)
- )
+ MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline)
+ .lineLimit(lines)
+ .padding(.bottom, 6)
}
private func ciQuoteIconView(_ image: String) -> some View {
@@ -277,18 +295,22 @@ struct FramedItemView: View {
}
}
- @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
+ @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let ft = text == "" ? [] : ci.formattedText
- let v = toggleSecrets(ft, $showSecrets, MsgContentView(
+ let v = MsgContentView(
chat: chat,
text: text,
formattedText: ft,
+ textStyle: .body,
meta: ci.meta,
+ mentions: ci.mentions,
+ userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
rightToLeft: rtl,
- showSecrets: showSecrets
- ))
+ prefix: txtPrefix
+ )
+ .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme)))
.multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -319,13 +341,12 @@ struct FramedItemView: View {
return videoWidth
}
}
-}
-@ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View {
- if let ft = ft, ft.contains(where: { $0.isSecret }) {
- v.onTapGesture { showSecrets.wrappedValue.toggle() }
- } else {
- v
+ private func showQuotedItemDoesNotExistAlert() {
+ AlertManager.shared.showAlertMsg(
+ title: "No message",
+ message: "This message was deleted or not received yet."
+ )
}
}
@@ -366,14 +387,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
struct FramedItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}
@@ -382,17 +403,18 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
}
}
@@ -400,17 +422,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
index a80c5412b6..10e5efa298 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
@@ -13,8 +13,8 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
- @EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem
+ var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
- scrollModel.scrollToItem(id: chatItem.id)
+ scrollToItemId?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
@@ -126,7 +126,7 @@ struct FullScreenMediaView: View {
.scaledToFit()
}
}
- .onTapGesture { showView = false }
+ .onTapGesture { showView = false } // this is used in full screen view, onTapGesture works
}
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
index 822dda4d06..47a30f6cf3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View {
case .msgBadHash:
AlertManager.shared.showAlert(Alert(
title: Text("Bad message hash"),
- message: Text("The hash of the previous message is different.") + Text("\n") +
- Text(decryptErrorReason) + Text("\n") +
+ message: Text("The hash of the previous message is different.") + textNewLine +
+ Text(decryptErrorReason) + textNewLine +
Text("Please report it to the developers.")
))
case .msgBadId: msgBadIdAlert()
@@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View {
message: Text("""
The ID of the next message is incorrect (less or equal to the previous).
It can happen because of some bug or when the connection is compromised.
- """) + Text("\n") +
+ """) + textNewLine +
Text("Please report it to the developers.")
))
}
@@ -69,9 +69,9 @@ struct CIMsgError: View {
}
.padding(.leading, 12)
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
- .onTapGesture(perform: onTap)
+ .simultaneousGesture(TapGesture().onEnded(onTap))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
index 25e06b9ea4..87a9b2ce61 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -12,17 +12,17 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
- @Binding var revealed: Bool
var body: some View {
- (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
+ (Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, theme))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
}
@@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View {
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey {
- switch chatItem.meta.itemDeleted {
- case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
- case .blocked: "blocked"
- case .blockedByAdmin: "blocked by admin"
- case .deleted, nil: "marked deleted"
+ if chatItem.meta.itemDeleted != nil, chatItem.isReport {
+ "archived report"
+ } else {
+ switch chatItem.meta.itemDeleted {
+ case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
+ case .blocked: "blocked"
+ case .blockedByAdmin: "blocked by admin"
+ case .deleted, nil: "marked deleted"
+ }
}
}
}
@@ -79,7 +83,10 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
- MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
+ MarkedDeletedItemView(
+ chat: Chat.sampleData,
+ chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
+ ).environment(\.revealed, true)
}
.previewLayout(.fixed(width: 360, height: 200))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 999f99b294..e04584dfff 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -11,49 +11,74 @@ import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
-private let noTyping = Text(" ")
-
-private let typingIndicators: [Text] = [
- (typing(.black) + typing() + typing()),
- (typing(.bold) + typing(.black) + typing()),
- (typing() + typing(.bold) + typing(.black)),
- (typing() + typing() + typing(.bold))
-]
-
-private func typing(_ w: Font.Weight = .light) -> Text {
- Text(".").fontWeight(w)
+private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString {
+ let res = NSMutableAttributedString()
+ for w in ws {
+ res.append(NSAttributedString(string: ".", attributes: [
+ .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w),
+ .kern: -2 as NSNumber,
+ .foregroundColor: UIColor(theme.colors.secondary)
+ ]))
+ }
+ return res
}
struct MsgContentView: View {
@ObservedObject var chat: Chat
+ @Environment(\.showTimestamp) var showTimestamp: Bool
+ @Environment(\.containerBackground) var containerBackground: UIColor
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
+ var textStyle: UIFont.TextStyle
var sender: String? = nil
var meta: CIMeta? = nil
+ var mentions: [String: CIMention]? = nil
+ var userMemberId: String? = nil
var rightToLeft = false
- var showSecrets: Bool
+ var prefix: NSAttributedString? = nil
+ @State private var showSecrets: Set = []
@State private var typingIdx = 0
@State private var timer: Timer?
+ @State private var typingIndicators: [NSAttributedString] = []
+ @State private var noTyping = NSAttributedString(string: " ")
+ @State private var phase: CGFloat = 0
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
+ let v = msgContentView()
if meta?.isLive == true {
- msgContentView()
- .onAppear { switchTyping() }
+ v.onAppear {
+ let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
+ noTyping = NSAttributedString(string: " ", attributes: [
+ .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular),
+ .kern: -2 as NSNumber,
+ .foregroundColor: UIColor(theme.colors.secondary)
+ ])
+ switchTyping()
+ }
.onDisappear(perform: stopTyping)
.onChange(of: meta?.isLive, perform: switchTyping)
.onChange(of: meta?.recent, perform: switchTyping)
} else {
- msgContentView()
+ v
}
}
private func switchTyping(_: Bool? = nil) {
if let meta = meta, meta.isLive && meta.recent {
+ if typingIndicators.isEmpty {
+ let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
+ typingIndicators = [
+ typing(theme, descr, [.black, .light, .light]),
+ typing(theme, descr, [.bold, .black, .light]),
+ typing(theme, descr, [.light, .bold, .black]),
+ typing(theme, descr, [.light, .light, .bold])
+ ]
+ }
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
- typingIdx = (typingIdx + 1) % typingIndicators.count
+ typingIdx = typingIdx + 1
}
} else {
stopTyping()
@@ -63,95 +88,276 @@ struct MsgContentView: View {
private func stopTyping() {
timer?.invalidate()
timer = nil
+ typingIdx = 0
}
- private func msgContentView() -> Text {
- var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
+ @inline(__always)
+ private func msgContentView() -> some View {
+ let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix)
+ let s = r.string
+ let t: Text
if let mt = meta {
if mt.isLive {
- v = v + typingIndicator(mt.recent)
+ s.append(typingIndicator(mt.recent))
}
- v = v + reserveSpaceForMeta(mt)
+ t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
+ } else {
+ t = Text(AttributedString(s))
}
- return v
+ return msgTextResultView(r, t, showSecrets: $showSecrets)
}
- private func typingIndicator(_ recent: Bool) -> Text {
- return (recent ? typingIndicators[typingIdx] : noTyping)
- .font(.body.monospaced())
- .kerning(-2)
- .foregroundColor(theme.colors.secondary)
+ @inline(__always)
+ private func typingIndicator(_ recent: Bool) -> NSAttributedString {
+ recent && !typingIndicators.isEmpty
+ ? typingIndicators[typingIdx % 4]
+ : noTyping
}
+ @inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
- (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
}
-func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
- let s = text
- var res: Text
- if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
- res = formatText(ft[0], preview, showSecret: showSecrets)
- var i = 1
- while i < ft.count {
- res = res + formatText(ft[i], preview, showSecret: showSecrets)
- i = i + 1
- }
- } else {
- res = Text(s)
- }
-
- if let i = icon {
- res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
- }
-
- if let s = sender {
- let t = Text(s)
- return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res
- } else {
- return res
- }
+func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil) -> some View {
+ t.if(r.hasSecrets, transform: hiddenSecretsView)
+ .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) }
}
-private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
- let t = ft.text
- if let f = ft.format {
- switch (f) {
- case .bold: return Text(t).bold()
- case .italic: return Text(t).italic()
- case .strikeThrough: return Text(t).strikethrough()
- case .snippet: return Text(t).font(.body.monospaced())
- case .secret: return
- showSecret
- ? Text(t)
- : Text(AttributedString(t, attributes: AttributeContainer([
- .foregroundColor: UIColor.clear as Any,
- .backgroundColor: UIColor.secondarySystemFill as Any
- ])))
- case let .colored(color): return Text(t).foregroundColor(color.uiColor)
- case .uri: return linkText(t, t, preview, prefix: "")
- case let .simplexLink(linkType, simplexUri, smpHosts):
- switch privacySimplexLinkModeDefault.get() {
- case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
- case .full: return linkText(t, simplexUri, preview, prefix: "")
- case .browser: return linkText(t, simplexUri, preview, prefix: "")
+@inline(__always)
+private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View {
+ return GeometryReader { g in
+ Rectangle()
+ .fill(Color.clear)
+ .contentShape(Rectangle())
+ .simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in
+ let t = event.translation
+ if t.width * t.width + t.height * t.height > 100 { return }
+ let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
+ let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
+ let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil)
+ let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit
+ var index: CFIndex?
+ if let lines = CTFrameGetLines(frame) as? [CTLine] {
+ var origins = [CGPoint](repeating: .zero, count: lines.count)
+ CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
+ for i in 0 ..< lines.count {
+ let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
+ if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) {
+ index = CTLineGetStringIndexForPosition(lines[i], point)
+ break
+ }
+ }
+ }
+ if let index, let (url, browser) = attributedStringLink(s, for: index) {
+ if browser {
+ openBrowserAlert(uri: url)
+ } else {
+ UIApplication.shared.open(url)
+ }
+ }
+ })
+ }
+
+ func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
+ var linkURL: URL?
+ var browser: Bool = false
+ s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
+ if index >= range.location && index < range.location + range.length {
+ if let url = attrs[linkAttrKey] as? NSURL {
+ linkURL = url.absoluteURL
+ browser = attrs[webLinkAttrKey] != nil
+ } else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
+ if showSecrets.wrappedValue.contains(i) {
+ showSecrets.wrappedValue.remove(i)
+ } else {
+ showSecrets.wrappedValue.insert(i)
+ }
+ }
+ stop.pointee = true
}
- case .email: return linkText(t, t, preview, prefix: "mailto:")
- case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
- } else {
- return Text(t)
+ return if let linkURL { (linkURL, browser) } else { nil }
}
}
-private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
- preview
- ? Text(s).foregroundColor(color).underline(color: color)
- : Text(AttributedString(s, attributes: AttributeContainer([
- .link: NSURL(string: prefix + link) as Any,
- .foregroundColor: uiColor as Any
- ]))).underline()
+func hiddenSecretsView(_ v: V) -> some View {
+ v.overlay(
+ GeometryReader { g in
+ let size = (g.size.width + g.size.height) / 1.4142
+ Image("vertical_logo")
+ .resizable(resizingMode: .tile)
+ .frame(width: size, height: size)
+ .rotationEffect(.degrees(45), anchor: .center)
+ .position(x: g.size.width / 2, y: g.size.height / 2)
+ .clipped()
+ .saturation(0.65)
+ .opacity(0.35)
+ }
+ .mask(v)
+ )
+}
+
+private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
+
+private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
+
+private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
+
+typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
+
+func messageText(
+ _ text: String,
+ _ formattedText: [FormattedText]?,
+ textStyle: UIFont.TextStyle = .body,
+ sender: String?,
+ preview: Bool = false,
+ mentions: [String: CIMention]?,
+ userMemberId: String?,
+ showSecrets: Set?,
+ backgroundColor: UIColor,
+ prefix: NSAttributedString? = nil
+) -> MsgTextResult {
+ let res = NSMutableAttributedString()
+ let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
+ let font = 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
+
+ if let sender {
+ if preview {
+ res.append(NSAttributedString(string: sender + ": ", attributes: plain))
+ } else {
+ var attrs = plain
+ attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize)
+ res.append(NSAttributedString(string: sender, attributes: attrs))
+ res.append(NSAttributedString(string: ": ", attributes: plain))
+ }
+ }
+
+ if let prefix {
+ res.append(prefix)
+ }
+
+ if let fts = formattedText, fts.count > 0 {
+ var bold: UIFont?
+ var italic: UIFont?
+ var snippet: 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 .italic:
+ italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
+ attrs[.font] = italic
+ case .strikeThrough:
+ attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ case .snippet:
+ snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
+ attrs[.font] = snippet
+ case .secret:
+ if let showSecrets {
+ if !showSecrets.contains(secretIdx) {
+ attrs[.foregroundColor] = UIColor.clear
+ attrs[.backgroundColor] = secretColor
+ }
+ attrs[secretAttrKey] = secretIdx
+ secretIdx += 1
+ handleTaps = true
+ } else {
+ attrs[.foregroundColor] = UIColor.clear
+ attrs[.backgroundColor] = secretColor
+ }
+ hasSecrets = true
+ case let .colored(color):
+ if let c = color.uiColor {
+ attrs[.foregroundColor] = UIColor(c)
+ }
+ case .uri:
+ attrs = linkAttrs()
+ if !preview {
+ let s = t.lowercased()
+ let link = s.hasPrefix("http://") || s.hasPrefix("https://")
+ ? t
+ : "https://" + t
+ attrs[linkAttrKey] = NSURL(string: link)
+ attrs[webLinkAttrKey] = true
+ handleTaps = true
+ }
+ case let .simplexLink(linkType, simplexUri, smpHosts):
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = NSURL(string: simplexUri)
+ handleTaps = true
+ }
+ if case .description = privacySimplexLinkModeDefault.get() {
+ t = simplexLinkText(linkType, smpHosts)
+ }
+ case let .mention(memberName):
+ if let m = mentions?[memberName] {
+ mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize)
+ attrs[.font] = mention
+ if let ref = m.memberRef {
+ let name: String = if let alias = ref.localAlias, alias != "" {
+ "\(alias) (\(ref.displayName))"
+ } else {
+ ref.displayName
+ }
+ if m.memberId == userMemberId {
+ attrs[.foregroundColor] = UIColor.tintColor
+ }
+ t = mentionText(name)
+ } else {
+ t = mentionText(memberName)
+ }
+ }
+ case .email:
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
+ handleTaps = true
+ }
+ case .phone:
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
+ handleTaps = true
+ }
+ 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!
+ }
+}
+
+@inline(__always)
+private func mentionText(_ name: String) -> String {
+ name.contains(" @") ? "@'\(name)'" : "@\(name)"
}
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
@@ -165,9 +371,9 @@ struct MsgContentView_Previews: PreviewProvider {
chat: Chat.sampleData,
text: chatItem.text,
formattedText: chatItem.formattedText,
+ textStyle: .body,
sender: chatItem.memberDisplayName,
- meta: chatItem.meta,
- showSecrets: false
+ meta: chatItem.meta
)
.environmentObject(Chat.sampleData)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
index 32993d1a76..dfc620c402 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
@@ -14,12 +14,11 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
- var ci: ChatItem
+ var chatItems: [ChatItem]
var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState
@State private var searchText: String = ""
- @FocusState private var searchFocused
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
@@ -42,12 +41,10 @@ struct ChatItemForwardingView: View {
.alert(item: $alert) { $0.alert }
}
- @ViewBuilder private func forwardListView() -> some View {
+ private func forwardListView() -> some View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
- searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
- .padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in
@@ -55,6 +52,7 @@ struct ChatItemForwardingView: View {
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
+ .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.modifier(ThemedBackground(grouped: true))
} else {
ZStack {
@@ -73,11 +71,14 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
- let prohibited = chat.prohibitedByPref(
- hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
- isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
- isVoice: ci.content.msgContent?.isVoice ?? false
- )
+ let prohibited = chatItems.map { ci in
+ chat.prohibitedByPref(
+ hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
+ isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
+ isVoice: ci.content.msgContent?.isVoice ?? false
+ )
+ }.contains(true)
+
Button {
if prohibited {
alert = SomeAlert(
@@ -93,10 +94,10 @@ struct ChatItemForwardingView: View {
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
- contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
+ contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
)
} else {
- composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
+ composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
@@ -123,7 +124,7 @@ struct ChatItemForwardingView: View {
#Preview {
ChatItemForwardingView(
- ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index f6a856dad1..cd75d1b0cd 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -14,6 +14,7 @@ struct ChatItemInfoView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var ci: ChatItem
+ var userMemberId: String?
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil
@@ -130,9 +131,9 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func details() -> some View {
+ private func details() -> some View {
let meta = ci.meta
- VStack(alignment: .leading, spacing: 16) {
+ return VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
@@ -196,7 +197,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func historyTab() -> some View {
+ private func historyTab() -> some View {
GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84
ScrollView {
@@ -226,12 +227,13 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
+ 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: UIColor(backgroundColor))
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(ci, theme))
+ .background(backgroundColor)
.modifier(ChatItemClipped())
.contextMenu {
if itemVersion.msgContent.text != "" {
@@ -256,9 +258,9 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
- @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
+ @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View {
if text != "" {
- TextBubble(text: text, formattedText: formattedText, sender: sender)
+ TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
} else {
Text("no text")
.italic()
@@ -271,14 +273,18 @@ struct ChatItemInfoView: View {
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
- @State private var showSecrets = false
+ var mentions: [String: CIMention]?
+ var userMemberId: String?
+ var backgroundColor: UIColor
+ @State private var showSecrets: Set = []
var body: some View {
- toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
+ let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor)
+ return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
}
}
- @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
+ private func quoteTab(_ qi: CIQuote) -> some View {
GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84
ScrollView {
@@ -296,9 +302,10 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- textBubble(qi.text, qi.formattedText, qi.getSender(nil))
+ 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: UIColor(backgroundColor))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
@@ -331,7 +338,7 @@ struct ChatItemInfoView: View {
: theme.appColors.receivedMessage
}
- @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
+ private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
@@ -351,8 +358,9 @@ struct ChatItemInfoView: View {
Button {
Task {
await MainActor.run {
- ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
- dismiss()
+ ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
+ dismiss()
+ }
}
}
} label: {
@@ -368,7 +376,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
+ private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6)
@@ -399,7 +407,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
@@ -414,7 +422,7 @@ struct ChatItemInfoView: View {
.frame(maxHeight: .infinity, alignment: .top)
}
- @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
@@ -450,20 +458,8 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
- let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
- switch status {
- case .rcvd:
- ZStack(alignment: .trailing) {
- Image(systemName: icon)
- .foregroundColor(statusColor.opacity(0.67))
- .padding(.trailing, 6)
- Image(systemName: icon)
- .foregroundColor(statusColor.opacity(0.67))
- }
- default:
- Image(systemName: icon)
- .foregroundColor(statusColor)
- }
+ let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
+ image.foregroundColor(statusColor)
}
if let (title, text) = status.statusInfo {
@@ -560,6 +556,6 @@ func localTimestamp(_ date: Date) -> String {
struct ChatItemInfoView_Previews: PreviewProvider {
static var previews: some View {
- ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
+ ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index 870fe30108..f5558bcd93 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -9,40 +9,71 @@
import SwiftUI
import SimpleXChat
+extension EnvironmentValues {
+ struct ShowTimestamp: EnvironmentKey {
+ static let defaultValue: Bool = true
+ }
+
+ struct Revealed: EnvironmentKey {
+ static let defaultValue: Bool = true
+ }
+
+ struct ContainerBackground: EnvironmentKey {
+ static let defaultValue: UIColor = .clear
+ }
+
+ var showTimestamp: Bool {
+ get { self[ShowTimestamp.self] }
+ set { self[ShowTimestamp.self] = newValue }
+ }
+
+ var revealed: Bool {
+ get { self[Revealed.self] }
+ set { self[Revealed.self] = newValue }
+ }
+
+ var containerBackground: UIColor {
+ get { self[ContainerBackground.self] }
+ set { self[ContainerBackground.self] = newValue }
+ }
+}
+
struct ChatItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
+ @Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
+ var scrollToItemId: (ChatItem.ID) -> Void
var maxWidth: CGFloat = .infinity
- @Binding var revealed: Bool
@Binding var allowMenu: Bool
init(
chat: Chat,
chatItem: ChatItem,
+ scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
- revealed: Binding,
allowMenu: Binding = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
+ self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
- _revealed = revealed
_allowMenu = allowMenu
}
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
- MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
+ MarkedDeletedItemView(chat: chat, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
- ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
+ ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -60,7 +91,7 @@ struct ChatItemView: View {
default: nil
}
}
- .flatMap { UIImage(base64Encoded: $0) }
+ .flatMap { imageFromBase64($0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
@@ -71,8 +102,8 @@ struct ChatItemView: View {
return FramedItemView(
chat: chat,
chatItem: chatItem,
+ scrollToItemId: scrollToItemId,
preview: preview,
- revealed: $revealed,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
@@ -84,9 +115,9 @@ struct ChatItemView: View {
struct ChatItemContentView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
- @Binding var revealed: Bool
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -118,7 +149,7 @@ struct ChatItemContentView: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
- CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
+ CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -152,7 +183,7 @@ struct ChatItemContentView: View {
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
- return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
+ return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
@@ -165,7 +196,7 @@ struct ChatItemContentView: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
- CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
+ CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -185,7 +216,7 @@ struct ChatItemContentView: View {
} else if ns.count == 0 {
Text("\(count) group events")
} else if count > ns.count {
- Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
+ Text(members) + textSpace + Text("and \(count - ns.count) other events")
} else {
Text(members)
}
@@ -216,7 +247,7 @@ func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
- chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
+ chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
@@ -226,16 +257,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
@@ -254,7 +286,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -265,7 +297,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -276,7 +308,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -287,7 +319,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -298,9 +330,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
}
+ .environment(\.revealed, true)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
new file mode 100644
index 0000000000..07034cf8ec
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
@@ -0,0 +1,511 @@
+//
+// ChatItemsLoader.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 17.12.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SimpleXChat
+import SwiftUI
+
+let TRIM_KEEP_COUNT = 200
+
+func apiLoadMessages(
+ _ chatId: ChatId,
+ _ pagination: ChatPagination,
+ _ chatState: ActiveChatState,
+ _ search: String = "",
+ _ openAroundItemId: ChatItem.ID? = nil,
+ _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 }
+) async {
+ let chat: Chat
+ let navInfo: NavigationInfo
+ do {
+ (chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
+ } catch let error {
+ logger.error("apiLoadMessages error: \(responseError(error))")
+ return
+ }
+
+ let chatModel = ChatModel.shared
+
+ // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
+ let paginationIsInitial = switch pagination { case .initial: true; default: false }
+ let paginationIsLast = switch pagination { case .last: true; default: false }
+ // When openAroundItemId is provided, chatId can be different too
+ if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled {
+ return
+ }
+
+ let unreadAfterItemId = chatState.unreadAfterItemId
+
+ let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
+ var newItems: [ChatItem] = []
+ switch pagination {
+ case .initial:
+ let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
+ if chatModel.getChat(chat.id) == nil {
+ chatModel.addChat(chat)
+ }
+ await MainActor.run {
+ chatModel.chatItemStatuses.removeAll()
+ ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
+ chatModel.updateChatInfo(chat.chatInfo)
+ chatState.splits = newSplits
+ if !chat.chatItems.isEmpty {
+ chatState.unreadAfterItemId = chat.chatItems.last!.id
+ }
+ chatState.totalAfter = navInfo.afterTotal
+ chatState.unreadTotal = chat.chatStats.unreadCount
+ chatState.unreadAfter = navInfo.afterUnread
+ chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+
+ PreloadState.shared.clear()
+ }
+ case let .before(paginationChatItemId, _):
+ newItems.append(contentsOf: oldItems)
+ let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
+ guard let indexInCurrentItems else { return }
+ let (newIds, _) = mapItemsToIds(chat.chatItems)
+ let wasSize = newItems.count
+ let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
+ let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
+ unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
+ )
+ let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
+ newItems.insert(contentsOf: chat.chatItems, at: insertAt)
+ let newReversed: [ChatItem] = newItems.reversed()
+ await MainActor.run {
+ ItemsModel.shared.reversedChatItems = newReversed
+ chatState.splits = modifiedSplits.newSplits
+ chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
+ }
+ case let .after(paginationChatItemId, _):
+ newItems.append(contentsOf: oldItems)
+ let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
+ guard let indexInCurrentItems else { return }
+
+ let mappedItems = mapItemsToIds(chat.chatItems)
+ let newIds = mappedItems.0
+ let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
+ mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
+ )
+ let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
+ let indexToAddIsLast = indexToAdd == newItems.count
+ newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
+ let new: [ChatItem] = newItems
+ let newReversed: [ChatItem] = newItems.reversed()
+ await MainActor.run {
+ ItemsModel.shared.reversedChatItems = newReversed
+ chatState.splits = newSplits
+ chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
+ // loading clear bottom area, updating number of unread items after the newest loaded item
+ if indexToAddIsLast {
+ chatState.unreadAfterNewestLoaded -= unreadInLoaded
+ }
+ }
+ case .around:
+ var newSplits: [Int64]
+ if openAroundItemId == nil {
+ newItems.append(contentsOf: oldItems)
+ newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
+ } else {
+ newSplits = []
+ }
+ let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
+ //indexToInsertAroundTest()
+ newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
+ newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
+ let newReversed: [ChatItem] = newItems.reversed()
+ let orderedSplits = newSplits
+ await MainActor.run {
+ ItemsModel.shared.reversedChatItems = newReversed
+ chatState.splits = orderedSplits
+ chatState.unreadAfterItemId = chat.chatItems.last!.id
+ chatState.totalAfter = navInfo.afterTotal
+ chatState.unreadTotal = chat.chatStats.unreadCount
+ chatState.unreadAfter = navInfo.afterUnread
+
+ if let openAroundItemId {
+ chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ ChatModel.shared.openAroundItemId = openAroundItemId
+ ChatModel.shared.chatId = chatId
+ } else {
+ // no need to set it, count will be wrong
+ // chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ }
+ PreloadState.shared.clear()
+ }
+ case .last:
+ newItems.append(contentsOf: oldItems)
+ let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
+ newItems.append(contentsOf: chat.chatItems)
+ let items = newItems
+ await MainActor.run {
+ ItemsModel.shared.reversedChatItems = items.reversed()
+ chatState.splits = newSplits
+ chatModel.updateChatInfo(chat.chatInfo)
+ chatState.unreadAfterNewestLoaded = 0
+ }
+ }
+}
+
+
+private class ModifiedSplits {
+ let oldUnreadSplitIndex: Int
+ let newUnreadSplitIndex: Int
+ let trimmedIds: Set
+ let newSplits: [Int64]
+
+ init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set, newSplits: [Int64]) {
+ self.oldUnreadSplitIndex = oldUnreadSplitIndex
+ self.newUnreadSplitIndex = newUnreadSplitIndex
+ self.trimmedIds = trimmedIds
+ self.newSplits = newSplits
+ }
+}
+
+private func removeDuplicatesAndModifySplitsOnBeforePagination(
+ _ unreadAfterItemId: Int64,
+ _ newItems: inout [ChatItem],
+ _ newIds: Set,
+ _ splits: [Int64],
+ _ visibleItemIndexes: ClosedRange
+) -> ModifiedSplits {
+ var oldUnreadSplitIndex: Int = -1
+ var newUnreadSplitIndex: Int = -1
+ var lastSplitIndexTrimmed: Int? = nil
+ var allowedTrimming = true
+ var index = 0
+ /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
+ let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT
+ let trimUpperBound = newItems.count - TRIM_KEEP_COUNT
+ let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1
+ var trimmedIds = Set()
+ let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1
+ let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT
+ let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1
+ var newSplits = splits
+
+ newItems.removeAll(where: {
+ let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
+ let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
+ // may disable it after clearing the whole split range
+ if !splits.isEmpty && $0.id == splits.first {
+ // trim only in one split range
+ allowedTrimming = false
+ }
+ let indexInSplits = splits.firstIndex(of: $0.id)
+ if let indexInSplits {
+ lastSplitIndexTrimmed = indexInSplits
+ }
+ if invisibleItemToTrim {
+ if prevItemWasTrimmed {
+ trimmedIds.insert($0.id)
+ } else {
+ newUnreadSplitIndex = index
+ // prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
+ // this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
+ if let lastSplitIndexTrimmed {
+ var new = newSplits
+ new[lastSplitIndexTrimmed] = $0.id
+ newSplits = new
+ } else {
+ newSplits = [$0.id] + newSplits
+ }
+ }
+ }
+ if unreadAfterItemId == $0.id {
+ oldUnreadSplitIndex = index
+ }
+ index += 1
+ return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id)
+ })
+ // will remove any splits that now becomes obsolete because items were merged
+ newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) }
+ return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits)
+}
+
+private func removeDuplicatesAndModifySplitsOnAfterPagination(
+ _ unreadInLoaded: Int,
+ _ paginationChatItemId: Int64,
+ _ newItems: inout [ChatItem],
+ _ newIds: Set,
+ _ chat: Chat,
+ _ splits: [Int64]
+) -> ([Int64], Int) {
+ var unreadInLoaded = unreadInLoaded
+ var firstItemIdBelowAllSplits: Int64? = nil
+ var splitsToRemove: Set = []
+ let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
+ // Currently, it should always load from split range
+ let loadingFromSplitRange = indexInSplitRanges != nil
+ let topSplits: [Int64]
+ var splitsToMerge: [Int64]
+ if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
+ splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count])
+ topSplits = Array(splits[0 ..< indexInSplitRanges + 1])
+ } else {
+ splitsToMerge = []
+ topSplits = []
+ }
+ newItems.removeAll(where: { new in
+ let duplicate = newIds.contains(new.id)
+ if loadingFromSplitRange && duplicate {
+ if splitsToMerge.contains(new.id) {
+ splitsToMerge.removeAll(where: { $0 == new.id })
+ splitsToRemove.insert(new.id)
+ } else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty {
+ // we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
+ firstItemIdBelowAllSplits = new.id
+ }
+ }
+ if duplicate && new.isRcvNew {
+ unreadInLoaded -= 1
+ }
+ return duplicate
+ })
+ var newSplits: [Int64] = []
+ if firstItemIdBelowAllSplits != nil {
+ // no splits below anymore, all were merged with bottom items
+ newSplits = topSplits
+ } else {
+ if !splitsToRemove.isEmpty {
+ var new = splits
+ new.removeAll(where: { splitsToRemove.contains($0) })
+ newSplits = new
+ }
+ let enlargedSplit = splits.firstIndex(of: paginationChatItemId)
+ if let enlargedSplit {
+ // move the split to the end of loaded items
+ var new = splits
+ new[enlargedSplit] = chat.chatItems.last!.id
+ newSplits = new
+ }
+ }
+ return (newSplits, unreadInLoaded)
+}
+
+private func removeDuplicatesAndUpperSplits(
+ _ newItems: inout [ChatItem],
+ _ chat: Chat,
+ _ splits: [Int64],
+ _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange
+) async -> [Int64] {
+ if splits.isEmpty {
+ removeDuplicates(&newItems, chat)
+ return splits
+ }
+
+ var newSplits = splits
+ let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
+ let (newIds, _) = mapItemsToIds(chat.chatItems)
+ var idsToTrim: [BoxedValue>] = []
+ idsToTrim.append(BoxedValue(Set()))
+ var index = 0
+ newItems.removeAll(where: {
+ let duplicate = newIds.contains($0.id)
+ if (!duplicate && visibleItemIndexes.lowerBound > index) {
+ idsToTrim.last?.boxedValue.insert($0.id)
+ }
+ if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) {
+ newSplits.remove(at: firstIndex)
+ // closing previous range. All items in idsToTrim that ends with empty set should be deleted.
+ // Otherwise, the last set should be excluded from trimming because it is in currently visible split range
+ idsToTrim.append(BoxedValue(Set()))
+ }
+
+ index += 1
+ return duplicate
+ })
+ if !idsToTrim.last!.boxedValue.isEmpty {
+ // it has some elements to trim from currently visible range which means the items shouldn't be trimmed
+ // Otherwise, the last set would be empty
+ idsToTrim.removeLast()
+ }
+ let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined()
+ if !allItemsToDelete.isEmpty {
+ newItems.removeAll(where: { allItemsToDelete.contains($0.id) })
+ }
+ return newSplits
+}
+
+private func removeDuplicatesAndUnusedSplits(
+ _ newItems: inout [ChatItem],
+ _ chat: Chat,
+ _ splits: [Int64]
+) async -> [Int64] {
+ if splits.isEmpty {
+ removeDuplicates(&newItems, chat)
+ return splits
+ }
+
+ var newSplits = splits
+ let (newIds, _) = mapItemsToIds(chat.chatItems)
+ newItems.removeAll(where: {
+ let duplicate = newIds.contains($0.id)
+ if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) {
+ newSplits.remove(at: firstIndex)
+ }
+ return duplicate
+ })
+ return newSplits
+}
+
+// ids, number of unread items
+private func mapItemsToIds(_ items: [ChatItem]) -> (Set, Int) {
+ var unreadInLoaded = 0
+ var ids: Set = Set()
+ var i = 0
+ while i < items.count {
+ let item = items[i]
+ ids.insert(item.id)
+ if item.isRcvNew {
+ unreadInLoaded += 1
+ }
+ i += 1
+ }
+ return (ids, unreadInLoaded)
+}
+
+private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
+ let (newIds, _) = mapItemsToIds(chat.chatItems)
+ newItems.removeAll { newIds.contains($0.id) }
+}
+
+private typealias SameTimeItem = (index: Int, item: ChatItem)
+
+// return (item index, split index)
+private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set) -> (Int, Int) {
+ guard to.count > 0, let lastNew = lastNew else { return (0, 0) }
+ // group sorting: item_ts, item_id
+ // everything else: created_at, item_id
+ let compareByTimeTs = chatType == .group
+ // in case several items have the same time as another item in the `to` array
+ var sameTime: [SameTimeItem] = []
+
+ // trying to find new split index for item looks difficult but allows to not use one more loop.
+ // The idea is to memorize how many splits were till any index (map number of splits until index)
+ // and use resulting itemIndex to decide new split index position.
+ // Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
+ var splitsTillIndex: [Int] = []
+ var splitsPerPrevIndex = 0
+
+ for i in 0 ..< to.count {
+ let item = to[i]
+
+ splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex
+ splitsTillIndex.append(splitsPerPrevIndex)
+
+ let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt)
+ if itemIsNewer || i + 1 == to.count {
+ if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
+ sameTime.append((i, item))
+ }
+ // time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them
+ let itemIndex: Int
+ if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) {
+ itemIndex = first.index
+ } else if sameTime.count == 1 {
+ itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1
+ } else {
+ itemIndex = itemIsNewer ? i : i + 1
+ }
+ let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)]
+ let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)]
+ return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex)
+ }
+
+ if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
+ sameTime.append(SameTimeItem(index: i, item: item))
+ } else {
+ sameTime = []
+ }
+ }
+ // shouldn't be here
+ return (to.count, splits.count)
+}
+
+private func indexToInsertAroundTest() {
+ func assert(_ one: (Int, Int), _ two: (Int, Int)) {
+ if one != two {
+ logger.debug("\(String(describing: one)) != \(String(describing: two))")
+ fatalError()
+ }
+ }
+
+ let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")]
+ let items1 = [
+ ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
+ ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1))
+
+ let items2 = [
+ ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
+ ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1))
+
+ let items3 = [
+ ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
+ ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1))
+
+ let items4 = [
+ ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0))
+
+ let items5 = [
+ ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1))
+
+ let items6 = [
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
+ ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0))
+
+ let items7 = [
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
+ ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
+ ]
+ assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0))
+
+ let items8 = [
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0))
+
+ let items9 = [
+ ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0))
+
+ let items10 = [
+ ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
+ ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
+ ]
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0))
+
+ let items11: [ChatItem] = []
+ assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0))
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
new file mode 100644
index 0000000000..0a55ed48cc
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
@@ -0,0 +1,456 @@
+//
+// ChatItemsMerger.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 02.12.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct MergedItems: Hashable, Equatable {
+ let items: [MergedItem]
+ let splits: [SplitRange]
+ // chat item id, index in list
+ let indexInParentItems: Dictionary
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.hashValue == rhs.hashValue
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine("\(items.hashValue)")
+ }
+
+ static func create(_ items: [ChatItem], _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems {
+ if items.isEmpty {
+ return MergedItems(items: [], splits: [], indexInParentItems: [:])
+ }
+
+ let unreadCount = chatState.unreadTotal
+
+ let unreadAfterItemId = chatState.unreadAfterItemId
+ let itemSplits = chatState.splits
+ var mergedItems: [MergedItem] = []
+ // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
+ var splitRanges: [SplitRange] = []
+ var indexInParentItems = Dictionary()
+ var index = 0
+ var unclosedSplitIndex: Int? = nil
+ var unclosedSplitIndexInParent: Int? = nil
+ var visibleItemIndexInParent = -1
+ var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
+ var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
+ var lastRangeInReversedForMergedItems: BoxedValue>? = nil
+ var recent: MergedItem? = nil
+ while index < items.count {
+ let item = items[index]
+ let prev = index >= 1 ? items[index - 1] : nil
+ let next = index + 1 < items.count ? items[index + 1] : nil
+ let category = item.mergeCategory
+ let itemIsSplit = itemSplits.contains(item.id)
+
+ if item.id == unreadAfterItemId {
+ unreadBefore = unreadCount - chatState.unreadAfter
+ }
+ if item.isRcvNew {
+ unreadBefore -= 1
+ }
+
+ let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
+ if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
+ let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
+ items.boxedValue.append(listItem)
+
+ if item.isRcvNew {
+ unreadIds.boxedValue.insert(item.id)
+ }
+ if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
+ if revealed {
+ lastRevealedIdsInMergedItems.boxedValue.append(item.id)
+ }
+ lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
+ }
+ } else {
+ visibleItemIndexInParent += 1
+ let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
+ if item.mergeCategory != nil {
+ if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
+ lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : [])
+ } else if revealed, let lastRevealedIdsInMergedItems {
+ lastRevealedIdsInMergedItems.boxedValue.append(item.id)
+ }
+ lastRangeInReversedForMergedItems = BoxedValue(index ... index)
+ recent = MergedItem.grouped(
+ items: BoxedValue([listItem]),
+ revealed: revealed,
+ revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
+ rangeInReversed: lastRangeInReversedForMergedItems!,
+ mergeCategory: item.mergeCategory,
+ unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()),
+ startIndexInReversedItems: index,
+ hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
+ )
+ } else {
+ lastRangeInReversedForMergedItems = nil
+ recent = MergedItem.single(
+ item: listItem,
+ startIndexInReversedItems: index,
+ hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
+ )
+ }
+ mergedItems.append(recent!)
+ }
+ if itemIsSplit {
+ // found item that is considered as a split
+ if let unclosedSplitIndex, let unclosedSplitIndexInParent {
+ // it was at least second split in the list
+ splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
+ }
+ unclosedSplitIndex = index
+ unclosedSplitIndexInParent = visibleItemIndexInParent
+ } else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
+ // just one split for the whole list, there will be no more, it's the end
+ splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
+ }
+ indexInParentItems[item.id] = visibleItemIndexInParent
+ index += 1
+ }
+ return MergedItems(
+ items: mergedItems,
+ splits: splitRanges,
+ indexInParentItems: indexInParentItems
+ )
+ }
+
+ // Use this check to ensure that mergedItems state based on currently actual state of global
+ // splits and reversedChatItems
+ func isActualState() -> Bool {
+ let im = ItemsModel.shared
+ // do not load anything if global splits state is different than in merged items because it
+ // will produce undefined results in terms of loading and placement of items.
+ // Same applies to reversedChatItems
+ return indexInParentItems.count == im.reversedChatItems.count &&
+ splits.count == im.chatState.splits.count &&
+ // that's just an optimization because most of the time only 1 split exists
+ ((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted())
+ }
+}
+
+
+enum MergedItem: Identifiable, Hashable, Equatable {
+ // equatable and hashable implementations allows to see the difference and correctly scroll to items we want
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.hash == rhs.hash
+ }
+
+ var id: Int64 { newest().item.id }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(hash)
+ }
+
+ var hash: String {
+ switch self {
+ case .single(_, _, let hash): hash + " 1"
+ case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)"
+ }
+ }
+
+ // the item that is always single, cannot be grouped and always revealed
+ case single(
+ item: ListItem,
+ startIndexInReversedItems: Int,
+ hash: String
+ )
+
+ /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
+ * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
+ * of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
+ * visible items in ChatView's EndlessScrollView */
+ case grouped (
+ items: BoxedValue<[ListItem]>,
+ revealed: Bool,
+ // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
+ // it's the same list instance for all Grouped items within revealed group
+ /** @see reveal */
+ revealedIdsWithinGroup: BoxedValue<[Int64]>,
+ rangeInReversed: BoxedValue>,
+ mergeCategory: CIMergeCategory?,
+ unreadIds: BoxedValue>,
+ startIndexInReversedItems: Int,
+ hash: String
+ )
+
+ func revealItems(_ reveal: Bool, _ revealedItems: Binding>) {
+ if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self {
+ var newRevealed = revealedItems.wrappedValue
+ var i = 0
+ if reveal {
+ while i < items.boxedValue.count {
+ newRevealed.insert(items.boxedValue[i].item.id)
+ i += 1
+ }
+ } else {
+ while i < revealedIdsWithinGroup.boxedValue.count {
+ newRevealed.remove(revealedIdsWithinGroup.boxedValue[i])
+ i += 1
+ }
+ revealedIdsWithinGroup.boxedValue.removeAll()
+ }
+ revealedItems.wrappedValue = newRevealed
+ }
+ }
+
+ var startIndexInReversedItems: Int {
+ get {
+ switch self {
+ case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems
+ case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems
+ }
+ }
+ }
+
+ func hasUnread() -> Bool {
+ switch self {
+ case let .single(item, _, _): item.item.isRcvNew
+ case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty
+ }
+ }
+
+ func newest() -> ListItem {
+ switch self {
+ case let .single(item, _, _): item
+ case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0]
+ }
+ }
+
+ func oldest() -> ListItem {
+ switch self {
+ case let .single(item, _, _): item
+ case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1]
+ }
+ }
+
+ func lastIndexInReversed() -> Int {
+ switch self {
+ case .single: startIndexInReversedItems
+ case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1
+ }
+ }
+}
+
+struct SplitRange {
+ let itemId: Int64
+ /** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
+ * so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
+ * (3, 4 indexes of the splitRange with the split itself at index 3)
+ * */
+ let indexRangeInReversed: ClosedRange
+ /** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
+ let indexRangeInParentItems: ClosedRange
+}
+
+struct ListItem: Hashable {
+ let item: ChatItem
+ let prevItem: ChatItem?
+ let nextItem: ChatItem?
+ // how many unread items before (older than) this one (excluding this one)
+ let unreadBefore: Int
+
+ private func chatDirHash(_ chatDir: CIDirection?) -> Int {
+ guard let chatDir else { return 0 }
+ return switch chatDir {
+ case .directSnd: 0
+ case .directRcv: 1
+ case .groupSnd: 2
+ case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
+ case .localSnd: 4
+ case .localRcv: 5
+ }
+ }
+
+ // using meta.hashValue instead of parts takes much more time so better to use partial meta here
+ func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String {
+ "\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)"
+ }
+}
+
+class ActiveChatState {
+ var splits: [Int64] = []
+ var unreadAfterItemId: Int64 = -1
+ // total items after unread after item (exclusive)
+ var totalAfter: Int = 0
+ var unreadTotal: Int = 0
+ // exclusive
+ var unreadAfter: Int = 0
+ // exclusive
+ var unreadAfterNewestLoaded: Int = 0
+
+ func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) {
+ guard let toItemId else { return }
+ let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId })
+ let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId })
+ guard let currentIndex, let newIndex else {
+ return
+ }
+ unreadAfterItemId = toItemId
+ let unreadDiff = newIndex > currentIndex
+ ? -nonReversedItems[currentIndex + 1.. fromIndex
+ ? -nonReversedItems[fromIndex + 1..?, _ newItems: [ChatItem]) {
+ guard let itemIds else {
+ // special case when the whole chat became read
+ unreadTotal = 0
+ unreadAfter = 0
+ return
+ }
+ var unreadAfterItemIndex: Int = -1
+ // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
+ var i = newItems.count - 1
+ var ids = itemIds
+ // intermediate variables to prevent re-setting state value a lot of times without reason
+ var newUnreadTotal = unreadTotal
+ var newUnreadAfter = unreadAfter
+ while i >= 0 {
+ let item = newItems[i]
+ if item.id == unreadAfterItemId {
+ unreadAfterItemIndex = i
+ }
+ if ids.contains(item.id) {
+ // was unread, now this item is read
+ if (unreadAfterItemIndex == -1) {
+ newUnreadAfter -= 1
+ }
+ newUnreadTotal -= 1
+ ids.remove(item.id)
+ if ids.isEmpty {
+ break
+ }
+ }
+ i -= 1
+ }
+ unreadTotal = newUnreadTotal
+ unreadAfter = newUnreadAfter
+ }
+
+ func itemAdded(_ item: (Int64, Bool), _ index: Int) {
+ if item.1 {
+ unreadAfter += 1
+ unreadTotal += 1
+ }
+ }
+
+ func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
+ var newSplits: [Int64] = []
+ for split in splits {
+ let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
+ // deleted the item that was right before the split between items, find newer item so it will act like the split
+ if let index {
+ let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
+ let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
+ // it the whole section is gone and splits overlap, don't add it at all
+ if let newSplit, !newSplits.contains(newSplit) {
+ newSplits.append(newSplit)
+ }
+ } else {
+ newSplits.append(split)
+ }
+ }
+ splits = newSplits
+
+ let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId })
+ // unread after item was removed
+ if let index {
+ let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
+ var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
+ let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
+ if newUnreadAfterItemId == nil {
+ // everything on top (including unread after item) were deleted, take top item as unread after id
+ newUnreadAfterItemId = newItems.first?.id
+ }
+ if let newUnreadAfterItemId {
+ unreadAfterItemId = newUnreadAfterItemId
+ totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
+ unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
+ unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
+ if newUnreadAfterItemWasNull {
+ // since the unread after item was moved one item after initial position, adjust counters accordingly
+ if newItems.first?.isRcvNew == true {
+ unreadTotal += 1
+ unreadAfter -= 1
+ }
+ }
+ } else {
+ // all items were deleted, 0 items in chatItems
+ unreadAfterItemId = -1
+ totalAfter = 0
+ unreadTotal = 0
+ unreadAfter = 0
+ }
+ } else {
+ totalAfter -= itemIds.count
+ }
+ }
+}
+
+class BoxedValue: Equatable, Hashable {
+ static func == (lhs: BoxedValue, rhs: BoxedValue) -> Bool {
+ lhs.boxedValue == rhs.boxedValue
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine("\(self)")
+ }
+
+ var boxedValue : T
+ init(_ value: T) {
+ self.boxedValue = value
+ }
+}
+
+@MainActor
+func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange {
+ let zero = 0 ... 0
+ let items = mergedItems.items
+ if items.isEmpty {
+ return zero
+ }
+ let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
+ let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
+ guard let newest, let oldest else {
+ return zero
+ }
+ let size = ItemsModel.shared.reversedChatItems.count
+ let range = size - oldest ... size - newest
+ if range.lowerBound < 0 || range.upperBound < 0 {
+ return zero
+ }
+
+ // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
+ return range
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
new file mode 100644
index 0000000000..c1a1eec7d2
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
@@ -0,0 +1,185 @@
+//
+// ChatScrollHelpers.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 20.12.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) async {
+ await MainActor.run {
+ loadingMoreItems.wrappedValue = true
+ loadingBottomItems.wrappedValue = true
+ }
+ try? await Task.sleep(nanoseconds: 500_000000)
+ if ChatModel.shared.chatId != chat.chatInfo.id {
+ await MainActor.run {
+ loadingMoreItems.wrappedValue = false
+ loadingBottomItems.wrappedValue = false
+ }
+ return
+ }
+ await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
+ await MainActor.run {
+ loadingMoreItems.wrappedValue = false
+ loadingBottomItems.wrappedValue = false
+ }
+}
+
+class PreloadState {
+ static let shared = PreloadState()
+ var prevFirstVisible: Int64 = Int64.min
+ var prevItemsCount: Int = 0
+ var preloading: Bool = false
+
+ func clear() {
+ prevFirstVisible = Int64.min
+ prevItemsCount = 0
+ preloading = false
+ }
+}
+
+func preloadIfNeeded(
+ _ allowLoadMoreItems: Binding,
+ _ ignoreLoadingRequests: Binding,
+ _ listState: EndlessScrollView.ListState,
+ _ mergedItems: BoxedValue,
+ loadItems: @escaping (Bool, ChatPagination) async -> Bool,
+ loadLastItems: @escaping () async -> Void
+) {
+ let state = PreloadState.shared
+ guard !listState.isScrolling && !listState.isAnimatedScrolling,
+ !state.preloading,
+ listState.totalItemsCount > 0
+ else {
+ return
+ }
+ if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
+ state.preloading = true
+ let allowLoadMore = allowLoadMoreItems.wrappedValue
+ Task {
+ defer { state.preloading = false }
+ var triedToLoad = true
+ await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
+ triedToLoad = await loadItems(false, pagination)
+ return triedToLoad
+ }
+ if triedToLoad {
+ state.prevFirstVisible = listState.firstVisibleItemId as! Int64
+ state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
+ }
+ // it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
+ // split will be merged with last items and position of scroll will change unexpectedly.
+ if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
+ await loadLastItems()
+ }
+ }
+ } else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
+ state.preloading = true
+ Task {
+ defer { state.preloading = false }
+ await loadLastItems()
+ }
+ }
+}
+
+func preloadItems(
+ _ mergedItems: MergedItems,
+ _ allowLoadMoreItems: Bool,
+ _ listState: EndlessScrollView.ListState,
+ _ ignoreLoadingRequests: Binding,
+ _ loadItems: @escaping (ChatPagination) async -> Bool)
+async {
+ let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1
+ let remaining = ChatPagination.UNTIL_PRELOAD_COUNT
+ let firstVisibleIndex = listState.firstVisibleItemIndex
+
+ if !(await preloadItemsBefore()) {
+ await preloadItemsAfter()
+ }
+
+ func preloadItemsBefore() async -> Bool {
+ let splits = mergedItems.splits
+ let lastVisibleIndex = listState.lastVisibleItemIndex
+ var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
+ let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
+ if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
+ lastIndexToLoadFrom = items.count - 1
+ }
+ let loadFromItemId: Int64?
+ if allowLoad, let lastIndexToLoadFrom {
+ let index = items.count - 1 - lastIndexToLoadFrom
+ loadFromItemId = index >= 0 ? items[index].id : nil
+ } else {
+ loadFromItemId = nil
+ }
+ guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else {
+ return false
+ }
+ let sizeWas = items.count
+ let firstItemIdWas = items.first?.id
+ let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
+ if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
+ ignoreLoadingRequests.wrappedValue = loadFromItemId
+ return false
+ }
+ return triedToLoad
+ }
+
+ func preloadItemsAfter() async {
+ let splits = mergedItems.splits
+ let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
+ // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
+ let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
+ if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
+ let index = split.indexRangeInReversed.lowerBound
+ if index >= 0 {
+ let loadFromItemId = reversedItems[index].id
+ _ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
+ }
+ }
+ }
+}
+
+func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView.ListState) -> ListItem? {
+ if listState.lastVisibleItemIndex < listState.items.count {
+ return listState.items[listState.lastVisibleItemIndex].oldest()
+ } else {
+ return listState.items.last?.oldest()
+ }
+}
+
+private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? {
+ for split in splits {
+ // before any split
+ if split.indexRangeInParentItems.lowerBound > firstVisibleIndex {
+ if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) {
+ return split.indexRangeInReversed.lowerBound - 1
+ }
+ break
+ }
+ let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex)
+ if containsInRange {
+ if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) {
+ return split.indexRangeInReversed.upperBound
+ }
+ break
+ }
+ }
+ return nil
+}
+
+/// Disable animation on iOS 15
+func withConditionalAnimation(
+ _ animation: Animation? = .default,
+ _ body: () throws -> Result
+) rethrows -> Result {
+ if #available(iOS 16.0, *) {
+ try withAnimation(animation, body)
+ } else {
+ try body()
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 655dd8aaed..c136ebc01b 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -16,36 +16,48 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared
+ @State var mergedItems: BoxedValue = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState))
+ @State var revealedItems: Set = Set()
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
- @StateObject private var scrollModel = ReverseListScrollModel()
- @StateObject private var floatingButtonModel = FloatingButtonModel()
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
+ @State private var selectedRange = NSRange()
@State private var keyboardVisible = false
+ @State private var keyboardHiddenDate = Date.now
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
- @State private var loadingItems = false
- @State private var firstPage = false
- @State private var revealedChatItem: ChatItem?
- @State private var searchMode = false
+ @State private var loadingMoreItems = false
+ @State private var loadingTopItems = false
+ @State private var requestedTopScroll = false
+ @State private var loadingBottomItems = false
+ @State private var requestedBottomScroll = false
+ @State private var showSearch = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
- @State private var groupLink: String?
+ @State private var groupLink: CreatedConnLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
+ @State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set? = nil
@State private var showDeleteSelectedMessages: Bool = false
+ @State private var showArchiveSelectedReports: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
+ @State private var allowLoadMoreItems: Bool = false
+ @State private var ignoreLoadingRequests: Int64? = nil
+ @State private var animatedScrollingInProgress: Bool = false
+ @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
+
+ @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero)
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -59,10 +71,9 @@ struct ChatView: View {
}
}
- @ViewBuilder
private var viewBody: some View {
let cInfo = chat.chatInfo
- ZStack {
+ return ZStack {
let wallpaperImage = theme.wallpaper.type.image
let wallpaperType = theme.wallpaper.type
let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background)
@@ -76,16 +87,35 @@ struct ChatView: View {
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
chatItemsList()
- floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
+ if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
+ GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
+ }
+ FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ }
+ )
}
connectingText()
if selectedChatItems == nil {
+ let reason = chat.chatInfo.userCantSendReason
ComposeView(
chat: chat,
composeState: $composeState,
- keyboardVisible: $keyboardVisible
+ keyboardVisible: $keyboardVisible,
+ keyboardHiddenDate: $keyboardHiddenDate,
+ selectedRange: $selectedRange,
+ disabledText: reason?.composeLabel
)
.disabled(!cInfo.sendMsgEnabled)
+ .if(!cInfo.sendMsgEnabled) { v in
+ v.disabled(true).onTapGesture {
+ AlertManager.shared.showAlertMsg(
+ title: "You can't send messages!",
+ message: reason?.alertMessage
+ )
+ }
+ }
} else {
SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems,
@@ -95,18 +125,25 @@ struct ChatView: View {
allowToDeleteSelectedMessagesForAll = forAll
showDeleteSelectedMessages = true
},
+ archiveItems: {
+ showArchiveSelectedReports = true
+ },
moderateItems: {
if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
- }
+ },
+ forwardItems: forwardSelectedMessages
)
}
}
+ if im.showLoadingProgress == chat.id {
+ ProgressView().scaleEffect(2)
+ }
}
.safeAreaInset(edge: .top) {
VStack(spacing: .zero) {
- if searchMode { searchToolbar() }
+ if showSearch { searchToolbar() }
Divider()
}
.background(ToolbarMaterial.material(toolbarMaterial))
@@ -129,43 +166,122 @@ struct ChatView: View {
}
}
}
- .appSheet(item: $selectedMember) { member in
- Group {
- if case let .group(groupInfo) = chat.chatInfo {
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
+ .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) {
+ Button("For me", role: .destructive) {
+ if let selected = selectedChatItems {
+ archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages)
+ }
+ }
+ if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
+ Button("For all moderators", role: .destructive) {
+ if let selected = selectedChatItems {
+ archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages)
+ }
}
}
}
+ .appSheet(item: $selectedMember) { member in
+ Group {
+ if case let .group(groupInfo) = chat.chatInfo {
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: member,
+ navigation: true
+ )
+ }
+ }
+ }
+ // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
+ .appSheet(isPresented: $showAddMembersSheet) {
+ Group {
+ if case let .group(groupInfo) = cInfo {
+ AddGroupMembersView(chat: chat, groupInfo: groupInfo)
+ }
+ }
+ }
+ .sheet(isPresented: Binding(
+ get: { !forwardedChatItems.isEmpty },
+ set: { isPresented in
+ if !isPresented {
+ forwardedChatItems = []
+ selectedChatItems = nil
+ }
+ }
+ )) {
+ if #available(iOS 16.0, *) {
+ ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
+ .presentationDetents([.fraction(0.8)])
+ } else {
+ ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
+ }
+ }
.onAppear {
+ scrollView.listState.onUpdateListener = onChatItemsUpdated
selectedChatItems = nil
+ revealedItems = Set()
initChatView()
+ if im.isLoading {
+ Task {
+ try? await Task.sleep(nanoseconds: 500_000000)
+ await MainActor.run {
+ if im.isLoading {
+ im.showLoadingProgress = chat.id
+ }
+ }
+ }
+ }
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
selectedChatItems = nil
- scrollModel.scrollToBottom()
+ revealedItems = Set()
stopAudioPlayer()
if let cId {
if let c = chatModel.getChat(cId) {
chat = c
}
+ scrollView.listState.onUpdateListener = onChatItemsUpdated
initChatView()
theme = buildTheme()
+ closeSearch()
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+
+ if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
+ scrollView.scrollToItem(index)
+ } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
+ scrollView.scrollToItem(unreadIndex)
+ } else {
+ scrollView.scrollToBottom()
+ }
+ if chatModel.openAroundItemId != nil {
+ chatModel.openAroundItemId = nil
+ }
} else {
dismiss()
}
}
- .onChange(of: revealedChatItem) { _ in
- NotificationCenter.postReverseListNeedsLayout()
- }
- .onChange(of: im.isLoading) { isLoading in
- if !isLoading,
- im.reversedChatItems.count <= loadItemsPerPage,
- filtered(im.reversedChatItems).count < 10 {
- loadChatItems(chat.chatInfo)
+ .onChange(of: chatModel.openAroundItemId) { openAround in
+ if let openAround {
+ closeSearch()
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ chatModel.openAroundItemId = nil
+
+ if let index = mergedItems.boxedValue.indexInParentItems[openAround] {
+ scrollView.scrollToItem(index)
+ }
+
+ // this may already being loading because of changed chat id (see .onChange(of: chat.id)
+ if !loadingBottomItems {
+ allowLoadMoreItems = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ allowLoadMoreItems = true
+ }
+ }
}
}
- .environmentObject(scrollModel)
.onDisappear {
VideoPlayerView.players.removeAll()
stopAudioPlayer()
@@ -174,6 +290,7 @@ struct ChatView: View {
if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
ItemsModel.shared.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
@@ -201,6 +318,8 @@ struct ChatView: View {
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias,
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
onSearch: { focusSearch() }
)
}
@@ -221,7 +340,8 @@ struct ChatView: View {
chat.created = Date.now
}
),
- onSearch: { focusSearch() }
+ onSearch: { focusSearch() },
+ localAlias: groupInfo.localAlias
)
}
} else if case .local = cInfo {
@@ -229,7 +349,6 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
- let isLoading = im.isLoading && im.showLoadingProgress
if selectedChatItems != nil {
Button {
withAnimation {
@@ -252,23 +371,19 @@ struct ChatView: View {
}
}
Menu {
- if !isLoading {
- if callsPrefEnabled && chatModel.activeCall == nil {
- Button {
- CallController.shared.startCall(contact, .video)
- } label: {
- Label("Video call", systemImage: "video")
- }
- .disabled(!contact.ready || !contact.active)
+ if callsPrefEnabled && chatModel.activeCall == nil {
+ Button {
+ CallController.shared.startCall(contact, .video)
+ } label: {
+ Label("Video call", systemImage: "video")
}
- searchButton()
- ToggleNtfsButton(chat: chat)
- .disabled(!contact.ready || !contact.active)
+ .disabled(!contact.ready || !contact.active)
}
+ searchButton()
+ ToggleNtfsButton(chat: chat)
+ .disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
- .tint(isLoading ? Color.clear : nil)
- .overlay { if isLoading { ProgressView() } }
}
}
case let .group(groupInfo):
@@ -287,20 +402,13 @@ struct ChatView: View {
}
} else {
addMembersButton()
- .appSheet(isPresented: $showAddMembersSheet) {
- AddGroupMembersView(chat: chat, groupInfo: groupInfo)
- }
}
}
Menu {
- if !isLoading {
- searchButton()
- ToggleNtfsButton(chat: chat)
- }
+ searchButton()
+ ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
- .tint(isLoading ? Color.clear : nil)
- .overlay { if isLoading { ProgressView() } }
}
}
case .local:
@@ -312,7 +420,7 @@ struct ChatView: View {
}
}
}
-
+
private func initChatView() {
let cInfo = chat.chatInfo
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
@@ -340,6 +448,40 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false)
}
}
+ floatingButtonModel.updateOnListChange(scrollView.listState)
+ }
+
+ private func scrollToItemId(_ itemId: ChatItem.ID) {
+ Task {
+ do {
+ var index = mergedItems.boxedValue.indexInParentItems[itemId]
+ if index == nil {
+ let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
+ let oldSize = ItemsModel.shared.reversedChatItems.count
+ let triedToLoad = await loadChatItems(chat, pagination)
+ if !triedToLoad {
+ return
+ }
+ var repeatsLeft = 50
+ while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
+ try await Task.sleep(nanoseconds: 20_000000)
+ repeatsLeft -= 1
+ }
+ index = mergedItems.boxedValue.indexInParentItems[itemId]
+ }
+ if let index {
+ closeKeyboardAndRun {
+ Task {
+ await MainActor.run { animatedScrollingInProgress = true }
+ await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
+ await MainActor.run { animatedScrollingInProgress = false }
+ }
+ }
+ }
+ } catch {
+ logger.error("Error scrolling to item: \(error)")
+ }
+ }
}
private func searchToolbar() -> some View {
@@ -363,16 +505,14 @@ struct ChatView: View {
.cornerRadius(10.0)
Button ("Cancel") {
- searchText = ""
- searchMode = false
- searchFocussed = false
- Task { await loadChat(chat: chat) }
+ closeSearch()
+ searchTextChanged("")
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
-
+
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
}
@@ -390,56 +530,84 @@ struct ChatView: View {
.map { $0.element }
}
-
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
- let mergedItems = filtered(im.reversedChatItems)
return GeometryReader { g in
- ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in
+ //let _ = logger.debug("Reloading chatItemsList with number of itmes: \(im.reversedChatItems.count)")
+ ScrollRepresentable(scrollView: scrollView) { (index: Int, mergedItem: MergedItem) in
+ let ci = switch mergedItem {
+ case let .single(item, _, _): item.item
+ case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue.last!.item
+ }
let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group
- ? voiceNoFrame
- ? (g.size.width - 28) - 42
- : (g.size.width - 28) * 0.84 - 42
- : voiceNoFrame
- ? (g.size.width - 32)
- : (g.size.width - 32) * 0.84
+ ? voiceNoFrame
+ ? (g.size.width - 28) - 42
+ : (g.size.width - 28) * 0.84 - 42
+ : voiceNoFrame
+ ? (g.size.width - 32)
+ : (g.size.width - 32) * 0.84
return ChatItemWithMenu(
chat: $chat,
+ index: index,
+ isLastItem: index == mergedItems.boxedValue.items.count - 1,
chatItem: ci,
+ scrollToItemId: scrollToItemId,
+ merged: mergedItem,
maxWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
- revealedChatItem: $revealedChatItem,
- selectedChatItems: $selectedChatItems
+ showChatInfoSheet: $showChatInfoSheet,
+ revealedItems: $revealedItems,
+ selectedChatItems: $selectedChatItems,
+ forwardedChatItems: $forwardedChatItems,
+ searchText: $searchText,
+ closeKeyboardAndRun: closeKeyboardAndRun
)
- .onAppear {
- floatingButtonModel.appeared(viewId: ci.viewId)
- }
- .onDisappear {
- floatingButtonModel.disappeared(viewId: ci.viewId)
- }
+ // crashes on Cell size calculation without this line
+ .environmentObject(ChatModel.shared)
+ .environmentObject(theme) // crashes without this line when scrolling to the first unread in EndlessScrollVIew
.id(ci.id) // Required to trigger `onAppear` on iOS15
- } loadPage: {
- loadChatItems(cInfo)
}
- .opacity(ItemsModel.shared.isLoading ? 0 : 1)
- .padding(.vertical, -InvertedTableView.inset)
- .onTapGesture { hideKeyboard() }
- .onChange(of: searchText) { _ in
- Task { await loadChat(chat: chat, search: searchText) }
+ .onAppear {
+ if !im.isLoading {
+ updateWithInitiallyLoadedItems()
+ }
}
- .onChange(of: im.reversedChatItems) { _ in
- floatingButtonModel.chatItemsChanged()
+ .onChange(of: im.isLoading) { loading in
+ if !loading {
+ updateWithInitiallyLoadedItems()
+ }
}
- .onChange(of: im.itemAdded) { added in
- if added {
+ .onChange(of: im.reversedChatItems) { items in
+ mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ if im.itemAdded {
im.itemAdded = false
- if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom {
- scrollModel.scrollToBottom()
+ if scrollView.listState.firstVisibleItemIndex < 2 {
+ scrollView.scrollToBottomAnimated()
+ } else {
+ scrollView.scroll(by: 34)
}
}
}
+ .onChange(of: revealedItems) { revealed in
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ }
+ .onChange(of: chat.id) { _ in
+ allowLoadMoreItems = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ allowLoadMoreItems = true
+ }
+ }
+ .padding(.vertical, -100)
+ .onTapGesture { hideKeyboard() }
+ .onChange(of: searchText) { s in
+ if showSearch {
+ searchTextChanged(s)
+ }
+ }
}
}
@@ -457,104 +625,271 @@ struct ChatView: View {
}
}
+ private func updateWithInitiallyLoadedItems() {
+ if mergedItems.boxedValue.items.isEmpty {
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState)
+ }
+ let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() })
+ let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil }
+ // this helps to speed up initial process of setting scroll position and reduce time needed
+ // to layout items on screen
+ if let unreadIndex, let unreadItemId {
+ scrollView.setScrollPosition(unreadIndex, unreadItemId)
+ }
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ if let unreadIndex {
+ scrollView.scrollToItem(unreadIndex)
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ allowLoadMoreItems = true
+ }
+ }
+
+ private func searchTextChanged(_ s: String) {
+ Task {
+ await loadChat(chat: chat, search: s)
+ mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ await MainActor.run {
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ }
+ if !s.isEmpty {
+ scrollView.scrollToBottom()
+ } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
+ // scroll to the top unread item
+ scrollView.scrollToItem(index)
+ } else {
+ scrollView.scrollToBottom()
+ }
+ }
+ }
+
class FloatingButtonModel: ObservableObject {
- private enum Event {
- case appeared(String)
- case disappeared(String)
- case chatItemsChanged
- }
+ @Published var unreadAbove: Int = 0
+ @Published var unreadBelow: Int = 0
+ @Published var isNearBottom: Bool = true
+ @Published var date: Date? = nil
+ @Published var isDateVisible: Bool = false
+ var hideDateWorkItem: DispatchWorkItem? = nil
- @Published var unreadChatItemCounts: UnreadChatItemCounts
-
- private let events = PassthroughSubject()
- private var bag = Set()
-
- init() {
- unreadChatItemCounts = UnreadChatItemCounts(
- isNearBottom: true,
- isReallyNearBottom: true,
- unreadBelow: 0
- )
- events
- .receive(on: DispatchQueue.global(qos: .background))
- .scan(Set()) { itemsInView, event in
- var updated = itemsInView
- switch event {
- case let .appeared(viewId): updated.insert(viewId)
- case let .disappeared(viewId): updated.remove(viewId)
- case .chatItemsChanged: ()
- }
- return updated
+ func updateOnListChange(_ listState: EndlessScrollView.ListState) {
+ let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
+ let unreadBelow = if let lastVisibleItem {
+ max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore)
+ } else {
+ 0
+ }
+ let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow
+ let date: Date? =
+ if let lastVisible = listState.visibleItems.last {
+ Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
+ } else {
+ nil
}
- .map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
- .removeDuplicates()
- .throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
- .assign(to: \.unreadChatItemCounts, on: self)
- .store(in: &bag)
+
+ // set the counters and date indicator
+ DispatchQueue.main.async { [weak self] in
+ guard let it = self else { return }
+ it.setDate(visibility: true)
+ it.unreadAbove = unreadAbove
+ it.unreadBelow = unreadBelow
+ it.date = date
+ }
+
+ // set floating button indication mode
+ let nearBottom = listState.firstVisibleItemIndex < 1
+ if nearBottom != self.isNearBottom {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
+ self?.isNearBottom = nearBottom
+ }
+ }
+
+ // hide Date indicator after 1 second of no scrolling
+ hideDateWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let it = self else { return }
+ it.setDate(visibility: false)
+ it.hideDateWorkItem = nil
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.hideDateWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
+ }
}
- func appeared(viewId: String) {
- events.send(.appeared(viewId))
+ func resetDate() {
+ date = nil
+ isDateVisible = false
}
- func disappeared(viewId: String) {
- events.send(.disappeared(viewId))
+ private func setDate(visibility isVisible: Bool) {
+ if isVisible {
+ if !isNearBottom,
+ !isDateVisible,
+ let date, !Calendar.current.isDateInToday(date) {
+ withAnimation { self.isDateVisible = true }
+ }
+ } else if isDateVisible {
+ withAnimation { self.isDateVisible = false }
+ }
}
- func chatItemsChanged() {
- events.send(.chatItemsChanged)
- }
}
- private func floatingButtons(counts: UnreadChatItemCounts) -> some View {
- VStack {
- let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
- if unreadAbove > 0 {
- circleButton {
- unreadCountText(unreadAbove)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
+ private struct FloatingButtons: View {
+ let theme: AppTheme
+ let scrollView: EndlessScrollView
+ let chat: Chat
+ @Binding var loadingMoreItems: Bool
+ @Binding var loadingTopItems: Bool
+ @Binding var requestedTopScroll: Bool
+ @Binding var loadingBottomItems: Bool
+ @Binding var requestedBottomScroll: Bool
+ @Binding var animatedScrollingInProgress: Bool
+ let listState: EndlessScrollView.ListState
+ @ObservedObject var model: FloatingButtonModel
+ let reloadItems: () -> Void
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ if let date = model.date {
+ DateSeparator(date: date)
+ .padding(.vertical, 4).padding(.horizontal, 8)
+ .background(.thinMaterial)
+ .clipShape(Capsule())
+ .opacity(model.isDateVisible ? 1 : 0)
+ .padding(.vertical, 4)
}
- .onTapGesture {
- scrollModel.scrollToNextPage()
- }
- .contextMenu {
- Button {
- Task {
- await markChatRead(chat)
+ VStack {
+ if model.unreadAbove > 0 && !animatedScrollingInProgress {
+ if loadingTopItems && requestedTopScroll {
+ circleButton { ProgressView() }
+ } else {
+ circleButton {
+ unreadCountText(model.unreadAbove)
+ .font(.callout)
+ .foregroundColor(theme.colors.primary)
+ }
+ .onTapGesture {
+ if loadingTopItems {
+ requestedTopScroll = true
+ requestedBottomScroll = false
+ } else {
+ scrollToTopUnread()
+ }
+ }
+ .contextMenu {
+ Button {
+ Task {
+ await markChatRead(chat)
+ }
+ } label: {
+ Label("Mark read", systemImage: "checkmark")
+ }
+ }
+ }
+ }
+ Spacer()
+ if listState.firstVisibleItemIndex != 0 && !animatedScrollingInProgress {
+ if loadingBottomItems && requestedBottomScroll {
+ circleButton { ProgressView() }
+ } else {
+ circleButton {
+ Group {
+ if model.unreadBelow > 0 {
+ unreadCountText(model.unreadBelow)
+ .font(.callout)
+ .foregroundColor(theme.colors.primary)
+ } else {
+ Image(systemName: "chevron.down").foregroundColor(theme.colors.primary)
+ }
+ }
+ }
+ .onTapGesture {
+ if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
+ requestedTopScroll = false
+ requestedBottomScroll = true
+ } else {
+ scrollToBottom()
+ }
+ }
}
- } label: {
- Label("Mark read", systemImage: "checkmark")
}
}
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
- Spacer()
- if counts.unreadBelow > 0 {
- circleButton {
- unreadCountText(counts.unreadBelow)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
+ .onChange(of: loadingTopItems) { loading in
+ if !loading && requestedTopScroll {
+ requestedTopScroll = false
+ scrollToTopUnread()
}
- .onTapGesture {
- scrollModel.scrollToBottom()
+ }
+ .onChange(of: loadingBottomItems) { loading in
+ if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
+ requestedBottomScroll = false
+ scrollToBottom()
}
- } else if !counts.isNearBottom {
- circleButton {
- Image(systemName: "chevron.down")
- .foregroundColor(theme.colors.primary)
+ }
+ .onDisappear(perform: model.resetDate)
+ }
+
+ private func scrollToTopUnread() {
+ Task {
+ if !ItemsModel.shared.chatState.splits.isEmpty {
+ await MainActor.run { loadingMoreItems = true }
+ await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
+ await MainActor.run { reloadItems() }
+ if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
+ await MainActor.run { animatedScrollingInProgress = true }
+ await scrollView.scrollToItemAnimated(index)
+ await MainActor.run { animatedScrollingInProgress = false }
+ }
+ await MainActor.run { loadingMoreItems = false }
+ } else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
+ await MainActor.run { animatedScrollingInProgress = true }
+ // scroll to the top unread item
+ await scrollView.scrollToItemAnimated(index)
+ await MainActor.run { animatedScrollingInProgress = false }
+ } else {
+ logger.debug("No more unread items, total: \(listState.items.count)")
}
- .onTapGesture { scrollModel.scrollToBottom() }
}
}
- .padding()
+
+ private func scrollToBottom() {
+ animatedScrollingInProgress = true
+ Task {
+ await scrollView.scrollToItemAnimated(0, top: false)
+ await MainActor.run { animatedScrollingInProgress = false }
+ }
+ }
+
+ private func circleButton(_ content: @escaping () -> Content) -> some View {
+ ZStack {
+ Circle()
+ .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
+ .frame(width: 44, height: 44)
+ content()
+ }
+ }
}
- private func circleButton(_ content: @escaping () -> Content) -> some View {
- ZStack {
- Circle()
- .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
- .frame(width: 44, height: 44)
- content()
+ private struct DateSeparator: View {
+ let date: Date
+
+ var body: some View {
+ Text(String.localizedStringWithFormat(
+ NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
+ date.formatted(.dateTime.weekday(.abbreviated)),
+ date.formatted(
+ Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
+ ? .dateTime.day().month(.abbreviated)
+ : .dateTime.day().month(.abbreviated).year()
+ )
+ ))
+ .font(.callout)
+ .fontWeight(.medium)
+ .foregroundStyle(.secondary)
}
}
@@ -568,8 +903,8 @@ struct ChatView: View {
private func endCallButton(_ call: Call) -> some View {
Button {
- if let uuid = call.callkitUUID {
- CallController.shared.endCall(callUUID: uuid)
+ if CallController.useCallKit(), let callUUID = call.callUUID {
+ CallController.shared.endCall(callUUID: callUUID)
} else {
CallController.shared.endCall(call: call) {}
}
@@ -587,11 +922,29 @@ struct ChatView: View {
}
private func focusSearch() {
- searchMode = true
+ showSearch = true
searchFocussed = true
searchText = ""
}
+ private func closeSearch() {
+ showSearch = false
+ searchText = ""
+ searchFocussed = false
+ }
+
+ private func closeKeyboardAndRun(_ action: @escaping () -> Void) {
+ var delay: TimeInterval = 0
+ if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch {
+ delay = 0.5
+ closeSearch()
+ hideKeyboard()
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ action()
+ }
+ }
+
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
@@ -648,43 +1001,148 @@ struct ChatView: View {
}
}
- private func loadChatItems(_ cInfo: ChatInfo) {
+ private func forwardSelectedMessages() {
Task {
- if loadingItems || firstPage { return }
- loadingItems = true
do {
- var reversedPage = Array()
- var chatItemsAvailable = true
- // Load additional items until the page is +50 large after merging
- while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
- let pagination: ChatPagination =
- if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
- .before(chatItemId: lastItem.id, count: loadItemsPerPage)
- } else {
- .last(count: loadItemsPerPage)
- }
- let chatItems = try await apiGetChatItems(
- type: cInfo.chatType,
- id: cInfo.apiId,
- pagination: pagination,
- search: searchText
+ if let selectedChatItems {
+ let (validItems, confirmation) = try await apiPlanForwardChatItems(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemIds: Array(selectedChatItems)
)
- chatItemsAvailable = !chatItems.isEmpty
- reversedPage.append(contentsOf: chatItems.reversed())
- }
- await MainActor.run {
- if reversedPage.count == 0 {
- firstPage = true
+ if let confirmation {
+ if validItems.count > 0 {
+ showAlert(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Forward %d message(s)?", comment: "alert title"),
+ validItems.count
+ ),
+ message: forwardConfirmationText(confirmation) + "\n" +
+ NSLocalizedString("Forward messages without files?", comment: "alert message")
+ ) {
+ switch confirmation {
+ case let .filesNotAccepted(fileIds):
+ [forwardAction(validItems), downloadAction(fileIds), cancelAlertAction]
+ default:
+ [forwardAction(validItems), cancelAlertAction]
+ }
+ }
+ } else {
+ showAlert(
+ NSLocalizedString("Nothing to forward!", comment: "alert title"),
+ message: forwardConfirmationText(confirmation)
+ ) {
+ switch confirmation {
+ case let .filesNotAccepted(fileIds):
+ [downloadAction(fileIds), cancelAlertAction]
+ default:
+ [okAlertAction]
+ }
+ }
+ }
} else {
- im.reversedChatItems.append(contentsOf: reversedPage)
+ await openForwardingSheet(validItems)
}
- loadingItems = false
}
- } catch let error {
- logger.error("apiGetChat error: \(responseError(error))")
- await MainActor.run { loadingItems = false }
+ } catch {
+ logger.error("Plan forward chat items failed: \(error.localizedDescription)")
}
}
+
+ func forwardConfirmationText(_ fc: ForwardConfirmation) -> String {
+ switch fc {
+ case let .filesNotAccepted(fileIds):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"),
+ fileIds.count
+ )
+ case let .filesInProgress(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ case let .filesMissing(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ case let .filesFailed(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ }
+ }
+
+ func forwardAction(_ items: [Int64]) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Forward messages", comment: "alert action"),
+ style: .default,
+ handler: { _ in Task { await openForwardingSheet(items) } }
+ )
+ }
+
+ func downloadAction(_ fileIds: [Int64]) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Download files", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ Task {
+ if let user = ChatModel.shared.currentUser {
+ await receiveFiles(user: user, fileIds: fileIds)
+ }
+ }
+ }
+ )
+ }
+
+ func openForwardingSheet(_ items: [Int64]) async {
+ let im = ItemsModel.shared
+ var items = Set(items)
+ var fci = [ChatItem]()
+ for reversedChatItem in im.reversedChatItems {
+ if items.contains(reversedChatItem.id) {
+ items.remove(reversedChatItem.id)
+ fci.insert(reversedChatItem, at: 0)
+ }
+ if items.isEmpty { break }
+ }
+ await MainActor.run { forwardedChatItems = fci }
+ }
+ }
+
+ private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
+ if loadingMoreItems { return false }
+ await MainActor.run {
+ loadingMoreItems = true
+ if case .before = pagination {
+ loadingTopItems = true
+ } else if case .after = pagination {
+ loadingBottomItems = true
+ }
+ }
+ let triedToLoad = await loadChatItemsUnchecked(chat, pagination)
+ await MainActor.run {
+ loadingMoreItems = false
+ if case .before = pagination {
+ loadingTopItems = false
+ } else if case .after = pagination {
+ loadingBottomItems = false
+ }
+ }
+ return triedToLoad
+ }
+
+ private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
+ await apiLoadMessages(
+ chat.chatInfo.id,
+ pagination,
+ im.chatState,
+ searchText,
+ nil,
+ { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
+ )
+ return true
}
func stopAudioPlayer() {
@@ -692,152 +1150,350 @@ struct ChatView: View {
VoiceItemState.chatView = [:]
}
+ func onChatItemsUpdated() {
+ if !mergedItems.boxedValue.isActualState() {
+ //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)")
+ return
+ }
+ floatingButtonModel.updateOnListChange(scrollView.listState)
+ preloadIfNeeded(
+ $allowLoadMoreItems,
+ $ignoreLoadingRequests,
+ scrollView.listState,
+ mergedItems,
+ loadItems: { unchecked, pagination in
+ if unchecked {
+ await loadChatItemsUnchecked(chat, pagination)
+ } else {
+ await loadChatItems(chat, pagination)
+ }
+ },
+ loadLastItems: {
+ if !loadingMoreItems {
+ await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
+ }
+ }
+ )
+ }
+
private struct ChatItemWithMenu: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
@Binding @ObservedObject var chat: Chat
+ @ObservedObject var dummyModel: ChatItemDummyModel = .shared
+ let index: Int
+ let isLastItem: Bool
let chatItem: ChatItem
+ let scrollToItemId: (ChatItem.ID) -> Void
+ let merged: MergedItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@Binding var selectedMember: GMember?
- @Binding var revealedChatItem: ChatItem?
+ @Binding var showChatInfoSheet: Bool
+ @Binding var revealedItems: Set
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@State private var deletingItems: [Int64] = []
@State private var showDeleteMessages = false
+ @State private var archivingReports: Set? = nil
+ @State private var showArchivingReports = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
- @State private var showForwardingSheet: Bool = false
+ @State private var msgWidth: CGFloat = 0
+ @State private var touchInProgress: Bool = false
@Binding var selectedChatItems: Set?
+ @Binding var forwardedChatItems: [ChatItem]
+
+ @Binding var searchText: String
+ var closeKeyboardAndRun: (@escaping () -> Void) -> Void
@State private var allowMenu: Bool = true
+ @State private var markedRead = false
+ @State private var markReadTask: Task? = nil
+ @State private var actionSheet: SomeActionSheet? = nil
- var revealed: Bool { chatItem == revealedChatItem }
+ var revealed: Bool { revealedItems.contains(chatItem.id) }
+
+ typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
+
+ private func reveal(_ yes: Bool) -> Void {
+ merged.revealItems(yes, $revealedItems)
+ }
+
+ func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation {
+ guard let prevItem else {
+ return ItemSeparation(timestamp: true, largeGap: true, date: nil)
+ }
+
+ let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir {
+ groupMember.groupMemberId == prevGroupMember.groupMemberId
+ } else {
+ chatItem.chatDir.sent == prevItem.chatDir.sent
+ }
+ let largeGap = !sameMemberAndDirection || prevItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
+
+ return ItemSeparation(
+ timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(prevItem.meta.itemTs),
+ largeGap: largeGap,
+ date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: prevItem.meta.itemTs) ? nil : prevItem.meta.itemTs
+ )
+ }
+
+ func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
+ let oldIsGroupRcv = switch older?.chatDir {
+ case .groupRcv: true
+ default: false
+ }
+ let sameMember = switch (older?.chatDir, current.chatDir) {
+ case (.groupRcv(let oldMember), .groupRcv(let member)):
+ oldMember.memberId == member.memberId
+ default:
+ false
+ }
+ if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
+ return true
+ } else {
+ return false
+ }
+ }
var body: some View {
- let (currIndex, _) = m.getNextChatItem(chatItem)
- let ciCategory = chatItem.mergeCategory
- let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
- let range = itemsRange(currIndex, prevHidden)
let im = ItemsModel.shared
- Group {
- if revealed, let range = range {
- let items = Array(zip(Array(range), im.reversedChatItems[range]))
- ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in
- let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
- chatItemView(ci, nil, prev)
- .overlay {
- if let selected = selectedChatItems, ci.canBeDeletedForSelf {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture {
- let checked = selected.contains(ci.id)
- selectUnselectChatItem(select: !checked, ci)
- }
- }
- }
- }
- } else {
- chatItemView(chatItem, range, prevItem)
+
+ let last = isLastItem ? im.reversedChatItems.last : nil
+ let listItem = merged.newest()
+ let item = listItem.item
+ let range: ClosedRange? = if case let .grouped(_, _, _, rangeInReversed, _, _, _, _) = merged {
+ rangeInReversed.boxedValue
+ } else {
+ nil
+ }
+ let showAvatar = shouldShowAvatar(item, listItem.nextItem)
+ let single = switch merged {
+ case .single: true
+ default: false
+ }
+ let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
+ return VStack(spacing: 0) {
+ if let last {
+ DateSeparator(date: last.meta.itemTs).padding(8)
+ }
+ chatItemListView(range, showAvatar, item, itemSeparation)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
- }
+ })
}
}
+ if let date = itemSeparation.date {
+ DateSeparator(date: date).padding(8)
}
}
.onAppear {
+ if markedRead {
+ return
+ } else {
+ markedRead = true
+ }
if let range {
- if let items = unreadItems(range) {
+ let (itemIds, unreadMentions) = unreadItemIds(range)
+ if !itemIds.isEmpty {
waitToMarkRead {
- for ci in items {
- await apiMarkChatItemRead(chat.chatInfo, ci)
- }
+ await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
- await apiMarkChatItemRead(chat.chatInfo, chatItem)
+ await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
}
}
}
+ .onDisappear {
+ markReadTask?.cancel()
+ markedRead = false
+ }
+ .actionSheet(item: $actionSheet) { $0.actionSheet }
+ // skip updating struct on touch if no need to show GoTo button
+ .if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) {
+ // long press listener steals taps from top-level listener, so repeating it's logic here as well
+ $0.onTapGesture {
+ hideKeyboard()
+ }
+ .onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in
+ touchInProgress = pressing
+ })
+ }
}
-
- private func unreadItems(_ range: ClosedRange) -> [ChatItem]? {
+
+ private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) {
let im = ItemsModel.shared
- let items = range.compactMap { i in
- if i >= 0 && i < im.reversedChatItems.count {
- let ci = im.reversedChatItems[i]
- return if ci.isRcvNew { ci } else { nil }
- } else {
- return nil
+ var unreadItems: [ChatItem.ID] = []
+ var unreadMentions: Int = 0
+
+ for i in range {
+ if i < 0 || i >= im.reversedChatItems.count {
+ break
+ }
+ let ci = im.reversedChatItems[i]
+ if ci.isRcvNew {
+ unreadItems.append(ci.id)
+ if ci.meta.userMention {
+ unreadMentions += 1
+ }
}
}
- return if items.isEmpty { nil } else { items }
+
+ return (unreadItems, unreadMentions)
}
-
+
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
- if m.chatId == chat.chatInfo.id {
- Task(operation: op)
+ markReadTask = Task {
+ do {
+ _ = try await Task.sleep(nanoseconds: 600_000000)
+ if m.chatId == chat.chatInfo.id {
+ await op()
+ }
+ } catch {
+ // task was cancelled
}
}
}
- @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View {
+ private var searchIsNotBlank: Bool {
+ get {
+ searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ }
+
+ @available(iOS 16.0, *)
+ struct MemberLayout: Layout {
+ let spacing: Double
+ let msgWidth: Double
+
+ private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
+ assert(subviews.count == 2, "member layout must contain exactly two subviews")
+ let roleSize = subviews[1].sizeThatFits(proposal)
+ let memberSize = subviews[0].sizeThatFits(
+ ProposedViewSize(
+ width: (proposal.width ?? msgWidth) - roleSize.width,
+ height: proposal.height
+ )
+ )
+ return (memberSize, roleSize)
+ }
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
+ let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
+ return CGSize(
+ width: min(
+ proposal.width ?? msgWidth,
+ max(msgWidth, roleSize.width + spacing + memberSize.width)
+ ),
+ height: max(memberSize.height, roleSize.height)
+ )
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
+ let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
+ subviews[0].place(
+ at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
+ proposal: ProposedViewSize(memberSize)
+ )
+ subviews[1].place(
+ at: CGPoint(
+ x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
+ y: bounds.midY - roleSize.height / 2
+ ),
+ proposal: ProposedViewSize(roleSize)
+ )
+ }
+ }
+
+ @ViewBuilder func chatItemListView(
+ _ range: ClosedRange?,
+ _ showAvatar: Bool,
+ _ ci: ChatItem,
+ _ itemSeparation: ItemSeparation
+ ) -> some View {
+ let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir,
- case let .group(groupInfo) = chat.chatInfo {
- let (prevMember, memCount): (GroupMember?, Int) =
- if let range = range {
- m.getPrevHiddenMember(member, range)
- } else {
- (nil, 1)
- }
- if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
+ case .group = chat.chatInfo {
+ if showAvatar {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
- let t = if memCount == 1 && member.memberRole > .member {
- Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
- } else {
- Text(memberNames(member, prevMember, memCount))
+ Group {
+ let (prevMember, memCount): (GroupMember?, Int) =
+ if let range = range {
+ m.getPrevHiddenMember(member, range)
+ } else {
+ (nil, 1)
+ }
+ if memCount == 1 && member.memberRole > .member {
+ Group {
+ if #available(iOS 16.0, *) {
+ MemberLayout(spacing: 16, msgWidth: msgWidth) {
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Text(member.memberRole.text)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ .padding(.trailing, 8)
+ }
+ } else {
+ HStack(spacing: 16) {
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Text(member.memberRole.text)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ .layoutPriority(1)
+ }
+ }
+ }
+ .frame(
+ maxWidth: maxWidth,
+ alignment: chatItem.chatDir.sent ? .trailing : .leading
+ )
+ } else {
+ Text(memberNames(member, prevMember, memCount))
+ .lineLimit(2)
+ }
}
- t
.font(.caption)
.foregroundStyle(.secondary)
- .lineLimit(2)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
- .padding(.top, 7)
+ .padding(.top, 3) // this is in addition to message sequence gap
}
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
- HStack(alignment: .top, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
- .onTapGesture {
- if let member = m.getGroupMember(member.groupMemberId) {
- selectedMember = member
+ .simultaneousGesture(TapGesture().onEnded {
+ if let mem = m.getGroupMember(member.groupMemberId) {
+ selectedMember = mem
} else {
- Task {
- await m.loadGroupMembers(groupInfo) {
- selectedMember = m.getGroupMember(member.groupMemberId)
- }
- }
+ let mem = GMember.init(member)
+ m.groupMembers.append(mem)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = mem
}
- }
- chatItemWithMenu(ci, range, maxWidth)
+ })
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
+ .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
}
}
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
.padding(.trailing)
.padding(.leading, 12)
} else {
@@ -846,11 +1502,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
- chatItemWithMenu(ci, range, maxWidth)
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
- .padding(.leading, memberImageSize + 8 + 12)
+ .padding(.leading, 10 + memberImageSize + 12)
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
}
} else {
HStack(alignment: .center, spacing: 0) {
@@ -863,10 +1519,10 @@ struct ChatView: View {
.padding(.leading)
}
}
- chatItemWithMenu(ci, range, maxWidth)
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.horizontal)
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
}
}
@@ -881,19 +1537,29 @@ struct ChatView: View {
}
}
- @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View {
+ func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
- VStack(alignment: alignment.horizontal, spacing: 3) {
- ChatItemView(
- chat: chat,
- chatItem: ci,
- maxWidth: maxWidth,
- revealed: .constant(revealed),
- allowMenu: $allowMenu
- )
- .modifier(ChatItemClipped(ci))
- .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
- .accessibilityLabel("")
+ return VStack(alignment: alignment.horizontal, spacing: 3) {
+ HStack {
+ if ci.chatDir.sent {
+ goToItemButton(true)
+ }
+ ChatItemView(
+ chat: chat,
+ chatItem: ci,
+ scrollToItemId: scrollToItemId,
+ maxWidth: maxWidth,
+ allowMenu: $allowMenu
+ )
+ .environment(\.revealed, revealed)
+ .environment(\.showTimestamp, itemSeparation.timestamp)
+ .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
+ .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
+ .accessibilityLabel("")
+ if !ci.chatDir.sent {
+ goToItemButton(false)
+ }
+ }
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions(ci)
.padding(.bottom, 4)
@@ -903,7 +1569,7 @@ struct ChatView: View {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal, moderate: false)
}
- if let di = deletingItem, di.meta.deletable && !di.localNote {
+ if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
deleteMessage(.cidmBroadcast, moderate: false)
}
@@ -914,20 +1580,28 @@ struct ChatView: View {
deleteMessages(chat, deletingItems, moderate: false)
}
}
+ .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) {
+ Button("For me", role: .destructive) {
+ if let reports = self.archivingReports {
+ archiveReports(chat.chatInfo, reports.sorted(), false)
+ self.archivingReports = []
+ }
+ }
+ if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
+ Button("For all moderators", role: .destructive) {
+ if let reports = self.archivingReports {
+ archiveReports(chat.chatInfo, reports.sorted(), true)
+ self.archivingReports = []
+ }
+ }
+ }
+ }
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
- ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
- }
- .sheet(isPresented: $showForwardingSheet) {
- if #available(iOS 16.0, *) {
- ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
- .presentationDetents([.fraction(0.8)])
- } else {
- ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
- }
+ ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo)
}
}
@@ -956,12 +1630,27 @@ struct ChatView: View {
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
-
- if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
- v.onTapGesture {
+ .if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
+ v.simultaneousGesture(TapGesture().onEnded {
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
+ })
+ }
+ switch chat.chatInfo {
+ case let .group(groupInfo):
+ v.contextMenu {
+ ReactionContextMenu(
+ groupInfo: groupInfo,
+ itemId: ci.id,
+ reactionCount: r,
+ selectedMember: $selectedMember,
+ profileRadius: profileRadius
+ )
}
- } else {
+ case let .direct(contact):
+ v.contextMenu {
+ contactReactionMenu(contact, r)
+ }
+ default:
v
}
}
@@ -970,7 +1659,12 @@ struct ChatView: View {
@ViewBuilder
private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View {
- if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
+ if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
+ if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator {
+ archiveReportButton(ci)
+ }
+ deleteButton(ci, label: "Delete report")
+ } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed {
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
availableReactions.count > 0 {
reactionsGroup
@@ -1020,8 +1714,16 @@ struct ChatView: View {
if !live || !ci.meta.isLive {
deleteButton(ci)
}
- if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd {
- moderateButton(ci, groupInfo)
+ if ci.chatDir != .groupSnd {
+ if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
+ moderateButton(ci, groupInfo)
+ } else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports),
+ case let .group(gInfo) = chat.chatInfo,
+ gInfo.membership.memberRole == .member
+ && !live
+ && composeState.voiceMessageRecordingState == .noRecording {
+ reportButton(ci)
+ }
}
} else if ci.meta.itemDeleted != nil {
if revealed {
@@ -1069,7 +1771,7 @@ struct ChatView: View {
var forwardButton: Button {
Button {
- showForwardingSheet = true
+ forwardedChatItems = [chatItem]
} label: {
Label(
NSLocalizedString("Forward", comment: "chat item action"),
@@ -1285,7 +1987,7 @@ struct ChatView: View {
private func hideButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = nil
+ reveal(false)
}
} label: {
Label(
@@ -1295,7 +1997,7 @@ struct ChatView: View {
}
}
- private func deleteButton(_ ci: ChatItem) -> Button {
+ private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button {
Button(role: .destructive) {
if !revealed,
let currIndex = m.getChatItemIndex(ci),
@@ -1317,10 +2019,7 @@ struct ChatView: View {
deletingItem = ci
}
} label: {
- Label(
- NSLocalizedString("Delete", comment: "chat item action"),
- systemImage: "trash"
- )
+ Label(label, systemImage: "trash")
}
}
@@ -1339,10 +2038,10 @@ struct ChatView: View {
AlertManager.shared.showAlert(Alert(
title: Text("Delete member message?"),
message: Text(
- groupInfo.fullGroupPreferences.fullDelete.on
- ? "The message will be deleted for all members."
- : "The message will be marked as moderated for all members."
- ),
+ groupInfo.fullGroupPreferences.fullDelete.on
+ ? "The message will be deleted for all members."
+ : "The message will be marked as moderated for all members."
+ ),
primaryButton: .destructive(Text("Delete")) {
deletingItem = ci
deleteMessage(.cidmBroadcast, moderate: true)
@@ -1357,10 +2056,19 @@ struct ChatView: View {
}
}
+ private func archiveReportButton(_ cItem: ChatItem) -> Button {
+ Button {
+ archivingReports = [cItem.id]
+ showArchivingReports = true
+ } label: {
+ Label("Archive report", systemImage: "archivebox")
+ }
+ }
+
private func revealButton(_ ci: ChatItem) -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = ci
+ reveal(true)
}
} label: {
Label(
@@ -1373,7 +2081,7 @@ struct ChatView: View {
private func expandButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = chatItem
+ reveal(true)
}
} label: {
Label(
@@ -1386,7 +2094,7 @@ struct ChatView: View {
private func shrinkButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = nil
+ reveal(false)
}
} label: {
Label (
@@ -1396,6 +2104,37 @@ struct ChatView: View {
}
}
+ private func reportButton(_ ci: ChatItem) -> Button {
+ Button(role: .destructive) {
+ var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in
+ .default(Text(reason.text)) {
+ withAnimation {
+ if composeState.editing {
+ composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason))
+ } else {
+ composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason))
+ }
+ }
+ }
+ }
+
+ buttons.append(.cancel())
+
+ actionSheet = SomeActionSheet(
+ actionSheet: ActionSheet(
+ title: Text("Report reason?"),
+ buttons: buttons
+ ),
+ id: "reportChatMessage"
+ )
+ } label: {
+ Label (
+ NSLocalizedString("Report", comment: "chat item action"),
+ systemImage: "flag"
+ )
+ }
+ }
+
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
@@ -1456,11 +2195,57 @@ struct ChatView: View {
} else {
m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem)
}
+ let deletedItem = itemDeletion.deletedChatItem.chatItem
+ if deletedItem.isActiveReport {
+ m.decreaseGroupReportsCounter(chat.chatInfo.id)
+ }
}
}
}
} catch {
- logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
+ logger.error("ChatView.deleteMessage error: \(error)")
+ }
+ }
+ }
+
+ @ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View {
+ if !r.userReacted || r.totalReacted > 1 {
+ Button { showChatInfoSheet = true } label: {
+ profileMenuItem(Text(contact.displayName), contact.image, radius: profileRadius)
+ }
+ }
+ if r.userReacted {
+ Button {} label: {
+ profileMenuItem(Text("you"), m.currentUser?.profile.image, radius: profileRadius)
+ }
+ .disabled(true)
+ }
+ }
+
+ func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View {
+ Image(systemName: image)
+ .resizable()
+ .frame(width: 13, height: 13)
+ .padding([alignStart ? .trailing : .leading], 10)
+ .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4))
+ .simultaneousGesture(TapGesture().onEnded(onClick))
+ }
+
+ @ViewBuilder
+ func goToItemButton(_ alignStart: Bool) -> some View {
+ let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId
+ if searchIsNotBlank {
+ goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) {
+ closeKeyboardAndRun {
+ ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id)
+ }
+ }
+ } else if let chatTypeApiIdMsgId {
+ goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
+ closeKeyboardAndRun {
+ let (chatType, apiId, msgId) = chatTypeApiIdMsgId
+ ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
+ }
}
}
}
@@ -1519,6 +2304,10 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
+ let deletedItem = di.deletedChatItem.chatItem
+ if deletedItem.isActiveReport {
+ ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
+ }
}
}
await onSuccess()
@@ -1529,6 +2318,37 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
}
}
+func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) {
+ if itemIds.count > 0 {
+ Task {
+ do {
+ let deleted = try await apiDeleteReceivedReports(
+ groupId: chatInfo.apiId,
+ itemIds: itemIds,
+ mode: forAll ? CIDeleteMode.cidmBroadcast : CIDeleteMode.cidmInternalMark
+ )
+
+ await MainActor.run {
+ for di in deleted {
+ if let toItem = di.toChatItem {
+ _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem)
+ } else {
+ ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
+ }
+ let deletedItem = di.deletedChatItem.chatItem
+ if deletedItem.isActiveReport {
+ ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id)
+ }
+ }
+ }
+ await onSuccess()
+ } catch {
+ logger.error("ChatView.archiveReports error: \(error.localizedDescription)")
+ }
+ }
+ }
+}
+
private func buildTheme() -> AppTheme {
if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) {
let perChatTheme = if case let .direct(contact) = chat.chatInfo {
@@ -1550,25 +2370,121 @@ private func buildTheme() -> AppTheme {
}
}
+struct ReactionContextMenu: View {
+ @EnvironmentObject var m: ChatModel
+ let groupInfo: GroupInfo
+ var itemId: Int64
+ var reactionCount: CIReactionCount
+ @Binding var selectedMember: GMember?
+ var profileRadius: CGFloat
+ @State private var memberReactions: [MemberReaction] = []
+
+ var body: some View {
+ groupMemberReactionList()
+ .task {
+ await loadChatItemReaction()
+ }
+ }
+
+ @ViewBuilder private func groupMemberReactionList() -> some View {
+ if memberReactions.isEmpty {
+ ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
+ textSpace
+ }
+ } else {
+ ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in
+ let mem = mr.groupMember
+ let userMember = mem.groupMemberId == groupInfo.membership.groupMemberId
+ Button {
+ if let member = m.getGroupMember(mem.groupMemberId) {
+ selectedMember = member
+ } else {
+ let member = GMember.init(mem)
+ m.groupMembers.append(member)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = member
+ }
+ } label: {
+ profileMenuItem(Text(mem.displayName), mem.image, radius: profileRadius)
+ }
+ .disabled(userMember)
+ }
+ }
+ }
+
+ private func loadChatItemReaction() async {
+ do {
+ let memberReactions = try await apiGetReactionMembers(
+ groupId: groupInfo.groupId,
+ itemId: itemId,
+ reaction: reactionCount.reaction
+ )
+ await MainActor.run {
+ self.memberReactions = memberReactions
+ }
+ } catch let error {
+ logger.error("apiGetReactionMembers error: \(responseError(error))")
+ }
+ }
+}
+
+func profileMenuItem(_ nameText: Text, _ image: String?, radius: CGFloat) -> some View {
+ HStack {
+ nameText
+ if let image, let img = imageFromBase64(image) {
+ Image(uiImage: maskToCustomShape(img, size: 30, radius: radius))
+ } else {
+ Image(systemName: "person.crop.circle")
+ }
+ }
+}
+
+func maskToCustomShape(_ image: UIImage, size: CGFloat, radius: CGFloat) -> UIImage {
+ let path = Path { path in
+ if radius >= 50 {
+ path.addEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
+ } else if radius <= 0 {
+ path.addRect(CGRect(x: 0, y: 0, width: size, height: size))
+ } else {
+ let cornerRadius = size * CGFloat(radius) / 100
+ path.addRoundedRect(
+ in: CGRect(x: 0, y: 0, width: size, height: size),
+ cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
+ style: .continuous
+ )
+ }
+ }
+
+ return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in
+ context.cgContext.addPath(path.cgPath)
+ context.cgContext.clip()
+ let scale = size / max(image.size.width, image.size.height)
+ let imageSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ let imageOrigin = CGPoint(
+ x: (size - imageSize.width) / 2,
+ y: (size - imageSize.height) / 2
+ )
+ image.draw(in: CGRect(origin: imageOrigin, size: imageSize))
+ }
+}
+
struct ToggleNtfsButton: View {
@ObservedObject var chat: Chat
var body: some View {
- Button {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
- } label: {
- if chat.chatInfo.ntfsEnabled {
- Label("Mute", systemImage: "speaker.slash")
- } else {
- Label("Unmute", systemImage: "speaker.wave.2")
+ if let nextMode = chat.chatInfo.nextNtfMode {
+ Button {
+ toggleNotifications(chat, enableNtfs: nextMode)
+ } label: {
+ Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon)
}
}
}
}
-func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
+func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
- chatSettings.enableNtfs = enableNtfs ? .all : .none
+ chatSettings.enableNtfs = enableNtfs
updateChatSettings(chat, chatSettings: chatSettings)
}
@@ -1583,6 +2499,9 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
do {
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
await MainActor.run {
+ let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false
+ ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite)
+ let wasUnread = chat.unreadTag
switch chat.chatInfo {
case var .direct(contact):
contact.chatSettings = chatSettings
@@ -1592,6 +2511,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
ChatModel.shared.updateGroup(groupInfo)
default: ()
}
+ ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
}
} catch let error {
logger.error("apiSetChatSettings error \(responseError(error))")
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
index df3a8caf55..14026d79d1 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
@@ -18,7 +18,7 @@ struct ComposeImageView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in
- UIImage(base64Encoded: image)
+ imageFromBase64(image)
}
if imgs.count == 0 {
ProgressView()
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
index f7f1a89299..e629a984df 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
@@ -18,7 +18,7 @@ struct ComposeLinkView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
- if let linkPreview = linkPreview {
+ if let linkPreview {
linkPreviewView(linkPreview)
} else {
ProgressView()
@@ -40,7 +40,7 @@ struct ComposeLinkView: View {
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) {
- if let uiImage = UIImage(base64Encoded: linkPreview.image) {
+ if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 78cae78cf5..8993de886f 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -11,6 +11,8 @@ import SimpleXChat
import SwiftyGif
import PhotosUI
+let MAX_NUMBER_OF_MENTIONS = 3
+
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
@@ -19,11 +21,12 @@ enum ComposePreview {
case filePreview(fileName: String, file: URL)
}
-enum ComposeContextItem {
+enum ComposeContextItem: Equatable {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
- case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
+ case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
+ case reportedItem(chatItem: ChatItem, reason: ReportReason)
}
enum VoiceMessageRecordingState {
@@ -38,31 +41,41 @@ struct LiveMessage {
var sentMsg: String?
}
+typealias MentionedMembers = [String: CIMention]
+
struct ComposeState {
var message: String
+ var parsedMessage: [FormattedText]
var liveMessage: LiveMessage? = nil
var preview: ComposePreview
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
+ var mentions: MentionedMembers = [:]
init(
message: String = "",
+ parsedMessage: [FormattedText] = [],
liveMessage: LiveMessage? = nil,
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem,
- voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
+ voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
+ mentions: MentionedMembers = [:]
) {
self.message = message
+ self.parsedMessage = parsedMessage
self.liveMessage = liveMessage
self.preview = preview
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
+ self.mentions = mentions
}
init(editingItem: ChatItem) {
- self.message = editingItem.content.text
+ let text = editingItem.content.text
+ self.message = text
+ self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem)
if let emc = editingItem.content.msgContent,
@@ -71,31 +84,51 @@ struct ComposeState {
} else {
self.voiceMessageRecordingState = .noRecording
}
+ self.mentions = editingItem.mentions ?? [:]
}
- init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
+ init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = ""
+ self.parsedMessage = []
self.preview = .noPreview
- self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
+ self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
}
func copy(
message: String? = nil,
+ parsedMessage: [FormattedText]? = nil,
liveMessage: LiveMessage? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil,
- voiceMessageRecordingState: VoiceMessageRecordingState? = nil
+ voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
+ mentions: MentionedMembers? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
+ parsedMessage: parsedMessage ?? self.parsedMessage,
liveMessage: liveMessage ?? self.liveMessage,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem,
- voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
+ voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState,
+ mentions: mentions ?? self.mentions
)
}
-
+
+ func mentionMemberName(_ name: String) -> String {
+ var n = 0
+ var tryName = name
+ while mentions[tryName] != nil {
+ n += 1
+ tryName = "\(name)_\(n)"
+ }
+ return tryName
+ }
+
+ var memberMentions: [String: Int64] {
+ self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
+ }
+
var editing: Bool {
switch contextItem {
case .editingItem: return true
@@ -112,17 +145,35 @@ struct ComposeState {
var forwarding: Bool {
switch contextItem {
- case .forwardingItem: return true
+ case .forwardingItems: return true
default: return false
}
}
-
+
+ var reporting: Bool {
+ switch contextItem {
+ case .reportedItem: return true
+ default: return false
+ }
+ }
+
+ var submittingValidReport: Bool {
+ switch contextItem {
+ case let .reportedItem(_, reason):
+ switch reason {
+ case .other: return !message.isEmpty
+ default: return true
+ }
+ default: return false
+ }
+ }
+
var sendEnabled: Bool {
switch preview {
case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
- default: return !message.isEmpty || forwarding || liveMessage != nil
+ default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
}
}
@@ -167,8 +218,15 @@ struct ComposeState {
}
}
+ var manyMediaPreviews: Bool {
+ switch preview {
+ case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
+ default: return false
+ }
+ }
+
var attachmentDisabled: Bool {
- if editing || forwarding || liveMessage != nil || inProgress { return true }
+ if editing || forwarding || liveMessage != nil || inProgress || reporting { return true }
switch preview {
case .noPreview: return false
case .linkPreview: return false
@@ -186,6 +244,15 @@ struct ComposeState {
}
}
+ var placeholder: String? {
+ switch contextItem {
+ case let .reportedItem(_, reason):
+ return reason.text
+ default:
+ return nil
+ }
+ }
+
var empty: Bool {
message == "" && noPreview
}
@@ -258,6 +325,9 @@ struct ComposeView: View {
@ObservedObject var chat: Chat
@Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool
+ @Binding var keyboardHiddenDate: Date
+ @Binding var selectedRange: NSRange
+ var disabledText: LocalizedStringKey? = nil
@State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false
@@ -280,8 +350,8 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
- @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
- @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
VStack(spacing: 0) {
@@ -290,6 +360,11 @@ struct ComposeView: View {
ContextInvitingContactMemberView()
Divider()
}
+
+ if case let .reportedItem(_, reason) = composeState.contextItem {
+ reportReasonView(reason)
+ Divider()
+ }
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
@@ -317,9 +392,9 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
- .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
+ .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25)
- .padding(.bottom, 12)
+ .padding(.bottom, 16)
.padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo,
@@ -336,6 +411,7 @@ struct ComposeView: View {
ZStack(alignment: .leading) {
SendMessageView(
composeState: $composeState,
+ selectedRange: $selectedRange,
sendMessage: { ttl in
sendMessage(ttl: ttl)
resetLinkPreview()
@@ -360,24 +436,19 @@ struct ComposeView: View {
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible,
+ keyboardHiddenDate: $keyboardHiddenDate,
sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: theme.colors.primary
)
.padding(.trailing, 12)
- .disabled(!chat.userCanSend)
+ .disabled(!chat.chatInfo.sendMsgEnabled)
- if chat.userIsObserver {
- Text("you are observer")
+ if let disabledText {
+ Text(disabledText)
.italic()
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
- .onTapGesture {
- AlertManager.shared.showAlertMsg(
- title: "You can't send messages!",
- message: "Please contact group admin."
- )
- }
}
}
}
@@ -388,21 +459,23 @@ struct ComposeView: View {
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
+ let parsedMsg = parseSimpleXMarkdown(msg)
+ composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
if composeState.linkPreviewAllowed {
if msg.count > 0 {
- showLinkPreview(msg)
+ showLinkPreview(parsedMsg)
} else {
resetLinkPreview()
hasSimplexLink = false
}
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
- (_, hasSimplexLink) = parseMessage(msg)
+ (_, hasSimplexLink) = getSimplexLink(parsedMsg)
} else {
hasSimplexLink = false
}
}
- .onChange(of: chat.userCanSend) { canSend in
- if !canSend {
+ .onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
+ if !sendEnabled {
cancelCurrentVoiceRecording()
clearCurrentDraft()
clearState()
@@ -452,7 +525,7 @@ struct ComposeView: View {
Task {
var media: [(String, UploadContent)] = []
for content in selected {
- if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
+ if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
media.append((img, content))
await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
@@ -544,7 +617,7 @@ struct ComposeView: View {
}
private func addMediaContent(_ content: UploadContent) async {
- if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
+ if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content))
@@ -679,6 +752,27 @@ struct ComposeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
+
+
+ private func reportReasonView(_ reason: ReportReason) -> some View {
+ let reportText = switch reason {
+ case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
+ case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
+ case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
+ case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
+ case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
+ case .unknown: "" // Should never happen
+ }
+
+ return Text(reportText)
+ .italic()
+ .font(.caption)
+ .padding(12)
+ .frame(minHeight: 44)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.thinMaterial)
+ }
+
@ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem {
@@ -687,7 +781,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem):
ContextItemView(
chat: chat,
- contextItem: quotedItem,
+ contextItems: [quotedItem],
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
@@ -695,18 +789,26 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
- contextItem: editingItem,
+ contextItems: [editingItem],
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
Divider()
- case let .forwardingItem(chatItem: forwardedItem, _):
+ case let .forwardingItems(chatItems, _):
ContextItemView(
chat: chat,
- contextItem: forwardedItem,
+ contextItems: chatItems,
contextIcon: "arrowshape.turn.up.forward",
+ cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
+ )
+ Divider()
+ case let .reportedItem(chatItem: reportedItem, _):
+ ContextItemView(
+ chat: chat,
+ contextItems: [reportedItem],
+ contextIcon: "flag",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
- showSender: false
+ contextIconForeground: Color.red
)
Divider()
}
@@ -724,21 +826,25 @@ struct ComposeView: View {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
+ let mentions = composeState.memberMentions
if !live {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending()
}
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation()
- } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
- sent = await forwardItem(ci, fromChatInfo, ttl)
+ } else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
+ // Composed text is send as a reply to the last forwarded item
+ sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
- sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
+ _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live)
+ } else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
+ sent = await send(reason, chatItemId: chatItem.id)
} else {
var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
@@ -747,36 +853,39 @@ struct ComposeView: View {
switch (composeState.preview) {
case .noPreview:
- sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
+ sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
case .linkPreview:
- sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
- case let .mediaPreviews(mediaPreviews: media):
+ sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
+ case let .mediaPreviews(media):
+ // TODO: CHECK THIS
let last = media.count - 1
+ var msgs: [ComposedMessage] = []
if last >= 0 {
for i in 0.. 0 {
+ // Sleep to allow `progressByTimeout` update be rendered
+ try? await Task.sleep(nanoseconds: 100_000000)
+ }
+ if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
+ msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
}
- _ = try? await Task.sleep(nanoseconds: 100_000000)
}
- if case (_, .video(_, _, _)) = media[last] {
- sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
- } else {
- sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
+ if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
+ msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
}
}
- if sent == nil {
- sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
+ if msgs.isEmpty {
+ msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
}
+ sent = await send(msgs, live: live, ttl: ttl).last
+
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
- sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
+ sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
- sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
+ sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
}
}
}
@@ -791,6 +900,20 @@ struct ComposeView: View {
}
return sent
+ func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
+ let (previewImage, uploadContent) = media
+ return switch uploadContent {
+ case let .simpleImage(image):
+ (saveImage(image), .image(text: text, image: previewImage))
+ case let .animatedImage(image):
+ (saveAnimImage(image), .image(text: text, image: previewImage))
+ case let .video(_, url, duration):
+ (moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
+ case .none:
+ nil
+ }
+ }
+
func sending() async {
await MainActor.run { composeState.inProgress = true }
}
@@ -817,7 +940,7 @@ struct ComposeView: View {
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
- msg: mc,
+ updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
live: live
)
await MainActor.run {
@@ -849,28 +972,13 @@ struct ComposeView: View {
return .voice(text: msgText, duration: duration)
case .file:
return .file(msgText)
+ case .report(_, let reason):
+ return .report(text: msgText, reason: reason)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
}
- func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- let (image, data) = imageData
- if let data = data, let savedFile = saveAnyImage(data) {
- return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
- }
- return nil
- }
-
- func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- let (image, data) = imageData
- if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
- ChatModel.shared.filesToDelete.remove(url)
- return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
- }
- return nil
- }
-
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
if !privacyEncryptLocalFilesGroupDefault.get() {
return CryptoFile.plain(fileName)
@@ -885,52 +993,93 @@ struct ComposeView: View {
return nil
}
}
+
+ func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
+ if let chatItems = await apiReportMessage(
+ groupId: chat.chatInfo.apiId,
+ chatItemId: chatItemId,
+ reportReason: reportReason,
+ reportText: msgText
+ ) {
+ await MainActor.run {
+ for chatItem in chatItems {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
+ }
+ return chatItems.first
+ }
+
+ return nil
+ }
+
+ func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
+ await send(
+ [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
+ live: live,
+ ttl: ttl
+ ).first
+ }
- func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- if let chatItem = chat.chatInfo.chatType == .local
- ? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
- : await apiSendMessage(
+ func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
+ if let chatItems = chat.chatInfo.chatType == .local
+ ? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
+ : await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
- file: file,
- quotedItemId: quoted,
- msg: mc,
live: live,
- ttl: ttl
+ ttl: ttl,
+ composedMessages: msgs
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ for chatItem in chatItems {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
}
- return chatItem
+ return chatItems
}
- if let file = file {
- removeFile(file.filePath)
+ for msg in msgs {
+ if let file = msg.fileSource {
+ removeFile(file.filePath)
+ }
}
- return nil
+ return []
}
- func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
- if let chatItem = await apiForwardChatItem(
+ func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
+ if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
- itemId: forwardedItem.id,
+ itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
await MainActor.run {
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ for chatItem in chatItems {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
+ if forwardedItems.count != chatItems.count {
+ showAlert(
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d messages not forwarded", comment: "alert title"),
+ forwardedItems.count - chatItems.count
+ ),
+ message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
+ )
+ }
}
- return chatItem
+ return chatItems
+ } else {
+ return []
}
- return nil
}
func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
- if let url = parseMessage(msgText).url,
+ if let parsedMsg = parseSimpleXMarkdown(msgText),
+ let url = getSimplexLink(parsedMsg).url,
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview)
@@ -941,14 +1090,6 @@ struct ComposeView: View {
return .text(msgText)
}
}
-
- func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
- switch img {
- case let .simpleImage(image): return saveImage(image)
- case let .animatedImage(image): return saveAnimImage(image)
- default: return nil
- }
- }
}
private func startVoiceMessageRecording() async {
@@ -1057,9 +1198,9 @@ struct ComposeView: View {
}
}
- private func showLinkPreview(_ s: String) {
+ private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
- (linkUrl, hasSimplexLink) = parseMessage(s)
+ (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -1076,8 +1217,8 @@ struct ComposeView: View {
}
}
- private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
- guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
+ private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
+ guard let parsedMsg else { return (nil, false) }
let url: URL? = if let uri = parsedMsg.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) {
@@ -1108,11 +1249,14 @@ struct ComposeView: View {
if pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in
- if let linkPreview = linkPreview,
- pendingLinkUrl == url {
+ if let linkPreview, pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
- pendingLinkUrl = nil
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ composeState = composeState.copy(preview: .noPreview)
+ }
}
+ pendingLinkUrl = nil
}
}
}
@@ -1129,18 +1273,23 @@ struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@State var composeState = ComposeState(message: "hello")
+ @State var selectedRange = NSRange()
return Group {
ComposeView(
chat: chat,
composeState: $composeState,
- keyboardVisible: Binding.constant(true)
+ keyboardVisible: Binding.constant(true),
+ keyboardHiddenDate: Binding.constant(Date.now),
+ selectedRange: $selectedRange
)
.environmentObject(ChatModel())
ComposeView(
chat: chat,
composeState: $composeState,
- keyboardVisible: Binding.constant(true)
+ keyboardVisible: Binding.constant(true),
+ keyboardHiddenDate: Binding.constant(Date.now),
+ selectedRange: $selectedRange
)
.environmentObject(ChatModel())
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index 6245bbe21f..845442c75f 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -12,9 +12,10 @@ import SimpleXChat
struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
- let contextItem: ChatItem
+ let contextItems: [ChatItem]
let contextIcon: String
let cancelContextItem: () -> Void
+ var contextIconForeground: Color? = nil
var showSender: Bool = true
var body: some View {
@@ -23,14 +24,23 @@ struct ContextItemView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
- .foregroundColor(theme.colors.secondary)
- if showSender, let sender = contextItem.memberDisplayName {
- VStack(alignment: .leading, spacing: 4) {
- Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
- msgContentView(lines: 2)
- }
+ .foregroundColor(contextIconForeground ?? theme.colors.secondary)
+ if let singleItem = contextItems.first, contextItems.count == 1 {
+ if showSender, let sender = singleItem.memberDisplayName {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
+ msgContentView(lines: 2, contextItem: singleItem)
+ }
+ } else {
+ msgContentView(lines: 3, contextItem: singleItem)
+ }
} else {
- msgContentView(lines: 3)
+ Text(
+ chat.chatInfo.chatType == .local
+ ? "Saving \(contextItems.count) messages"
+ : "Forwarding \(contextItems.count) messages"
+ )
+ .italic()
}
Spacer()
Button {
@@ -45,29 +55,40 @@ struct ContextItemView: View {
.padding(12)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .background(chatItemFrameColor(contextItem, theme))
+ .background(background)
}
- private func msgContentView(lines: Int) -> some View {
- contextMsgPreview()
+ private var background: Color {
+ contextItems.first
+ .map { chatItemFrameColor($0, theme) }
+ ?? Color(uiColor: .tertiarySystemBackground)
+ }
+
+ private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
+ contextMsgPreview(contextItem)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines)
}
- private func contextMsgPreview() -> Text {
- return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ 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))
+ let t = attachment() + Text(AttributedString(r.string))
+ return t.if(r.hasSecrets, transform: hiddenSecretsView)
func attachment() -> Text {
+ let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
+ FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
+ } else { false }
switch contextItem.content.msgContent {
- case .file: return image("doc.fill")
+ case .file: return isFileLoaded ? image("doc.fill") : Text("")
case .image: return image("photo")
- case .voice: return image("play.fill")
+ case .voice: return isFileLoaded ? image("play.fill") : Text("")
default: return Text("")
}
}
func image(_ s: String) -> Text {
- Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
+ Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + textSpace
}
}
}
@@ -75,6 +96,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
- return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
+ return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
index ad47b7351a..d809fd7b76 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
@@ -16,18 +16,15 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool
@Binding var height: CGFloat
@Binding var focused: Bool
+ @Binding var lastUnfocusedDate: Date
+ @Binding var placeholder: String?
+ @Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void
- private let minHeight: CGFloat = 37
+ static let minHeight: CGFloat = 39
- private let defaultHeight: CGFloat = {
- let field = CustomUITextField(height: Binding.constant(0))
- field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
- return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
- }()
-
- func makeUIView(context: Context) -> UITextView {
- let field = CustomUITextField(height: _height)
+ func makeUIView(context: Context) -> CustomUITextField {
+ let field = CustomUITextField(parent: self, height: _height)
field.backgroundColor = .clear
field.text = text
field.textAlignment = alignment(text)
@@ -36,10 +33,9 @@ struct NativeTextEditor: UIViewRepresentable {
if !disableEditing {
text = newText
field.textAlignment = alignment(text)
- updateFont(field)
+ field.updateFont()
// Speed up the process of updating layout, reduce jumping content on screen
- updateHeight(field)
- self.height = field.frame.size.height
+ field.updateHeight()
} else {
field.text = text
}
@@ -47,42 +43,32 @@ struct NativeTextEditor: UIViewRepresentable {
onImagesAdded(images)
}
}
- field.setOnFocusChangedListener { focused = $0 }
+ field.setOnFocusChangedListener {
+ focused = $0
+ if !focused {
+ lastUnfocusedDate = .now
+ }
+ }
field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
- updateFont(field)
- updateHeight(field)
+ field.setPlaceholderView()
+ field.updateFont()
+ field.updateHeight(updateBindingNow: false)
return field
}
- func updateUIView(_ field: UITextView, context: Context) {
+ func updateUIView(_ field: CustomUITextField, context: Context) {
if field.markedTextRange == nil && field.text != text {
field.text = text
field.textAlignment = alignment(text)
- updateFont(field)
- updateHeight(field)
+ field.updateFont()
+ field.updateHeight(updateBindingNow: false)
}
- }
-
- private func updateHeight(_ field: UITextView) {
- let maxHeight = min(360, field.font!.lineHeight * 12)
- // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
- let newHeight = field.text == ""
- ? defaultHeight
- : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
-
- if field.frame.size.height != newHeight {
- field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
- (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
+ if field.placeholder != placeholder {
+ field.placeholder = placeholder
}
- }
-
- private func updateFont(_ field: UITextView) {
- let newFont = isShortEmoji(field.text)
- ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
- : UIFont.preferredFont(forTextStyle: .body)
- if field.font != newFont {
- field.font = newFont
+ if field.selectedRange != selectedRange {
+ field.selectedRange = selectedRange
}
}
}
@@ -91,17 +77,26 @@ private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left
}
-private class CustomUITextField: UITextView, UITextViewDelegate {
+class CustomUITextField: UITextView, UITextViewDelegate {
+ var parent: NativeTextEditor?
var height: Binding
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
-
- init(height: Binding) {
+
+ private let placeholderLabel: UILabel = UILabel()
+
+ init(parent: NativeTextEditor?, height: Binding) {
+ self.parent = parent
self.height = height
super.init(frame: .zero, textContainer: nil)
}
+ var placeholder: String? {
+ get { placeholderLabel.text }
+ set { placeholderLabel.text = newValue }
+ }
+
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
@@ -114,16 +109,63 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
invalidateIntrinsicContentSize()
}
- override var intrinsicContentSize: CGSize {
- if height.wrappedValue != newHeight {
- DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
+ func updateHeight(updateBindingNow: Bool = true) {
+ let maxHeight = min(360, font!.lineHeight * 12)
+ let newHeight = min(max(sizeThatFits(CGSizeMake(frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down)
+
+ if self.newHeight != newHeight {
+ frame.size = CGSizeMake(frame.size.width, newHeight)
+ invalidateIntrinsicContentHeight(newHeight)
+ if updateBindingNow {
+ self.height.wrappedValue = newHeight
+ } else {
+ DispatchQueue.main.async {
+ self.height.wrappedValue = newHeight
+ }
+ }
}
- return CGSizeMake(0, newHeight)
+ }
+
+ func updateFont() {
+ let newFont = isShortEmoji(text)
+ ? (text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
+ : UIFont.preferredFont(forTextStyle: .body)
+ if font != newFont {
+ font = newFont
+ // force apply new font because it has problem with doing it when the field had two emojis
+ if text.count == 0 {
+ text = " "
+ text = ""
+ }
+ }
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ updateHeight()
+ }
+
+ override var intrinsicContentSize: CGSize {
+ CGSizeMake(0, newHeight)
}
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged
}
+
+ func setPlaceholderView() {
+ placeholderLabel.textColor = .lightGray
+ placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body)
+ placeholderLabel.isHidden = !text.isEmpty
+ placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(placeholderLabel)
+
+ NSLayoutConstraint.activate([
+ placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7),
+ placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
+ placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8)
+ ])
+ }
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged
@@ -172,6 +214,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
}
func textViewDidChange(_ textView: UITextView) {
+ placeholderLabel.isHidden = !text.isEmpty
if textView.markedTextRange == nil {
var images: [UploadContent] = []
var rangeDiff = 0
@@ -203,10 +246,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
onFocusChanged(true)
+ updateSelectedRange(textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
onFocusChanged(false)
+ updateSelectedRange(textView)
+ }
+
+ func textViewDidChangeSelection(_ textView: UITextView) {
+ updateSelectedRange(textView)
+ }
+
+ private func updateSelectedRange(_ textView: UITextView) {
+ if parent?.selectedRange != textView.selectedRange {
+ parent?.selectedRange = textView.selectedRange
+ }
}
}
@@ -217,6 +272,9 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false),
height: Binding.constant(100),
focused: Binding.constant(false),
+ lastUnfocusedDate: Binding.constant(.now),
+ placeholder: Binding.constant("Placeholder"),
+ selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
onImagesAdded: { _ in }
)
.fixedSize(horizontal: false, vertical: true)
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index 9ad6e986bd..e7b02c9aea 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -13,7 +13,9 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
@Binding var composeState: ComposeState
+ @Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme
+ @Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
@@ -31,8 +33,9 @@ struct SendMessageView: View {
@State private var holdingVMR = false
@Namespace var namespace
@Binding var keyboardVisible: Bool
+ @Binding var keyboardHiddenDate: Date
var sendButtonColor = Color.accentColor
- @State private var teHeight: CGFloat = 42
+ @State private var teHeight: CGFloat = NativeTextEditor.minHeight
@State private var teFont: Font = .body
@State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1
@@ -40,54 +43,57 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false
- @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
+ @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
- ZStack {
- let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
- HStack(alignment: .bottom) {
- ZStack(alignment: .leading) {
- if case .voicePreview = composeState.preview {
- Text("Voice message…")
- .font(teFont.italic())
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.secondary)
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity)
- } else {
- NativeTextEditor(
- text: $composeState.message,
- disableEditing: $composeState.inProgress,
- height: $teHeight,
- focused: $keyboardVisible,
- onImagesAdded: onMediaAdded
- )
- .allowsTightening(false)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- if progressByTimeout {
- ProgressView()
- .scaleEffect(1.4)
- .frame(width: 31, height: 31, alignment: .center)
- .padding([.bottom, .trailing], 3)
- } else {
- VStack(alignment: .trailing) {
- if teHeight > 100 && !composeState.inProgress {
- deleteTextButton()
- Spacer()
- }
- composeActionButtons()
- }
- .frame(height: teHeight, alignment: .bottom)
- }
+ let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
+ ZStack(alignment: .leading) {
+ if case .voicePreview = composeState.preview {
+ Text("Voice message…")
+ .font(teFont.italic())
+ .multilineTextAlignment(.leading)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .padding(.trailing, 32)
+ .frame(maxWidth: .infinity)
+ } else {
+ NativeTextEditor(
+ text: $composeState.message,
+ disableEditing: $composeState.inProgress,
+ height: $teHeight,
+ focused: $keyboardVisible,
+ lastUnfocusedDate: $keyboardHiddenDate,
+ placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
+ selectedRange: $selectedRange,
+ onImagesAdded: onMediaAdded
+ )
+ .padding(.trailing, 32)
+ .allowsTightening(false)
+ .fixedSize(horizontal: false, vertical: true)
}
- .padding(.vertical, 1)
- .background(theme.colors.background)
- .clipShape(composeShape)
- .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
}
+ .overlay(alignment: .topTrailing, content: {
+ if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
+ deleteTextButton()
+ }
+ })
+ .overlay(alignment: .bottomTrailing, content: {
+ if progressByTimeout {
+ ProgressView()
+ .scaleEffect(1.4)
+ .frame(width: 31, height: 31, alignment: .center)
+ .padding([.bottom, .trailing], 4)
+ } else {
+ composeActionButtons()
+ // required for intercepting clicks
+ .background(.white.opacity(0.000001))
+ }
+ })
+ .padding(.vertical, 1)
+ .background(theme.colors.background)
+ .clipShape(composeShape)
+ .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
if inProgress {
@@ -105,6 +111,8 @@ struct SendMessageView: View {
let vmrs = composeState.voiceMessageRecordingState
if nextSendGrpInv {
inviteMemberContactButton()
+ } else if case .reportedItem = composeState.contextItem {
+ sendMessageButton()
} else if showVoiceMessageButton
&& composeState.message.isEmpty
&& !composeState.editing
@@ -164,7 +172,7 @@ struct SendMessageView: View {
!composeState.sendEnabled ||
composeState.inProgress
)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -187,7 +195,7 @@ struct SendMessageView: View {
composeState.endLiveDisabled ||
disableSendButton
)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.contextMenu{
sendButtonContextMenuItems()
}
@@ -227,6 +235,7 @@ struct SendMessageView: View {
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
+ !composeState.manyMediaPreviews,
let send = sendLiveMessage,
let update = updateLiveMessage {
Button {
@@ -247,6 +256,7 @@ struct SendMessageView: View {
}
private struct RecordVoiceMessageButton: View {
+ @Environment(\.isEnabled) var isEnabled
@EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@@ -255,15 +265,14 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil
var body: some View {
- Button(action: {}) {
- Image(systemName: "mic.fill")
- .resizable()
- .scaledToFit()
- .frame(width: 20, height: 20)
- .foregroundColor(theme.colors.primary)
- }
+ Image(systemName: isEnabled ? "mic.fill" : "mic")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
+ .opacity(holdingVMR ? 0.7 : 1)
.disabled(disabled)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
._onButtonGesture { down in
if down {
@@ -271,9 +280,7 @@ struct SendMessageView: View {
pressed = ProcessInfo.processInfo.systemUptime
startVoiceMessageRecording?()
} else {
- let now = ProcessInfo.processInfo.systemUptime
- if let pressed = pressed,
- now - pressed >= 1 {
+ if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 {
finishVoiceMessageRecording?()
}
holdingVMR = false
@@ -319,7 +326,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -347,7 +354,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
- .foregroundColor(theme.colors.primary)
+ .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)
@@ -404,7 +411,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.primary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -420,8 +427,10 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
@State var composeStateNew = ComposeState()
+ @State var selectedRange = NSRange()
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var composeStateEditing = ComposeState(editingItem: ci)
+ @State var selectedRangeEditing = NSRange()
@State var sendEnabled: Bool = true
return Group {
@@ -430,9 +439,11 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateNew,
+ selectedRange: $selectedRange,
sendMessage: { _ in },
onMediaAdded: { _ in },
- keyboardVisible: Binding.constant(true)
+ keyboardVisible: Binding.constant(true),
+ keyboardHiddenDate: Binding.constant(Date.now)
)
}
VStack {
@@ -440,9 +451,11 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateEditing,
+ selectedRange: $selectedRangeEditing,
sendMessage: { _ in },
onMediaAdded: { _ in },
- keyboardVisible: Binding.constant(true)
+ keyboardVisible: Binding.constant(true),
+ keyboardHiddenDate: Binding.constant(Date.now)
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
index b3fab958bc..e4489e46ee 100644
--- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
@@ -14,9 +14,10 @@ struct ContactPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contact: Contact
- @State var featuresAllowed: ContactFeaturesAllowed
- @State var currentFeaturesAllowed: ContactFeaturesAllowed
+ @Binding var featuresAllowed: ContactFeaturesAllowed
+ @Binding var currentFeaturesAllowed: ContactFeaturesAllowed
@State private var showSaveDialogue = false
+ let savePreferences: () -> Void
var body: some View {
let user: User = chatModel.currentUser!
@@ -48,7 +49,10 @@ struct ContactPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ featuresAllowed = currentFeaturesAllowed
+ dismiss()
+ }
}
}
@@ -118,31 +122,15 @@ struct ContactPreferencesView: View {
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
Text(feature.enabledDescription(enabled))
}
-
- private func savePreferences() {
- Task {
- do {
- let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
- if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
- await MainActor.run {
- contact = toContact
- chatModel.updateContact(toContact)
- currentFeaturesAllowed = featuresAllowed
- }
- }
- } catch {
- logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
- }
- }
- }
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
contact: Binding.constant(Contact.sampleData),
- featuresAllowed: ContactFeaturesAllowed.sampleData,
- currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
+ featuresAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
+ currentFeaturesAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
+ savePreferences: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift
new file mode 100644
index 0000000000..cc61754b26
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift
@@ -0,0 +1,715 @@
+//
+// EndlessScrollView.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 25.01.2025.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ScrollRepresentable: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
+
+ let scrollView: EndlessScrollView
+ let content: (Int, ScrollItem) -> Content
+
+ func makeUIViewController(context: Context) -> ScrollController {
+ ScrollController.init(scrollView: scrollView, content: content)
+ }
+
+ func updateUIViewController(_ controller: ScrollController, context: Context) {}
+
+ class ScrollController: UIViewController {
+ let scrollView: EndlessScrollView
+ fileprivate var items: [ScrollItem] = []
+ fileprivate var content: ((Int, ScrollItem) -> Content)!
+
+ fileprivate init(scrollView: EndlessScrollView, content: @escaping (Int, ScrollItem) -> Content) {
+ self.scrollView = scrollView
+ self.content = content
+ super.init(nibName: nil, bundle: nil)
+ self.view = scrollView
+ scrollView.createCell = createCell
+ scrollView.updateCell = updateCell
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView {
+ let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil
+ let cell: UIView
+ if #available(iOS 16.0, *), false {
+ let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell
+ if let item {
+ c.contentConfiguration = UIHostingConfiguration { self.content(index, item) }
+ .margins(.all, 0)
+ .minSize(height: 1) // Passing zero will result in system default of 44 points being used
+ }
+ cell = c
+ } else {
+ let c = cellsToReuse.isEmpty ? HostingCell() : cellsToReuse.removeLast() as! HostingCell
+ if let item {
+ c.set(content: self.content(index, item), parent: self)
+ }
+ cell = c
+ }
+ cell.isHidden = false
+ cell.backgroundColor = .clear
+ let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
+ cell.frame.size.width = scrollView.bounds.width
+ cell.frame.size.height = size.height
+ return cell
+ }
+
+ private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) {
+ let item = items[index]
+ if #available(iOS 16.0, *), false {
+ (cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) }
+ .margins(.all, 0)
+ .minSize(height: 1) // Passing zero will result in system default of 44 points being used
+ } else {
+ if let cell = cell as? HostingCell {
+ cell.set(content: self.content(index, item), parent: self)
+ } else {
+ fatalError("Unexpected Cell Type for: \(item)")
+ }
+ }
+ let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
+ cell.frame.size.width = scrollView.bounds.width
+ cell.frame.size.height = size.height
+ cell.setNeedsLayout()
+ }
+ }
+}
+
+class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable {
+
+ /// Stores actual state of the scroll view and all elements drawn on the screen
+ let listState: ListState = ListState()
+
+ /// Just some random big number that will probably be enough to scrolling down and up without reaching the end
+ var initialOffset: CGFloat = 100000000
+
+ /// Default item id when no items in the visible items list. Something that will never be in real data
+ fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } }
+
+ /// Storing an offset that was already used for laying down content to be able to see the difference
+ var prevProcessedOffset: CGFloat = 0
+
+ /// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content
+ /// starts from bottom and ends at top, not vice versa as usual
+ var oldScreenHeight: CGFloat = 0
+
+ /// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen
+ var estimatedContentHeight: ContentHeight = ContentHeight()
+
+ /// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too
+ var averageItemHeight: CGFloat = 30
+
+ /// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed
+ var scrollStepMultiplier: CGFloat = 0.37
+
+ /// Adds content padding to top
+ var insetTop: CGFloat = 100
+
+ /// Adds content padding to bottom
+ var insetBottom: CGFloat = 100
+
+ var scrollToItemIndexDelayed: Int? = nil
+
+ /// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight]
+ let scrollBarView: UIScrollView = UIScrollView(frame: .zero)
+
+ /// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch
+ var cellsToReuse: [UIView] = []
+
+ /// Enable debug to see hundreds of logs
+ var debug: Bool = false
+
+ var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil }
+ var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in }
+
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ self.delegate = self
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ class ListState: NSObject {
+
+ /// Will be called on every change of the items array, visible items, and scroll position
+ var onUpdateListener: () -> Void = {}
+
+ /// Items that were used to lay out the screen
+ var items: [ScrollItem] = [] {
+ didSet {
+ onUpdateListener()
+ }
+ }
+
+ /// It is equai to the number of [items]
+ var totalItemsCount: Int {
+ items.count
+ }
+
+ /// The items with their positions and other useful information. Only those that are visible on screen
+ var visibleItems: [EndlessScrollView.VisibleItem] = []
+
+ /// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen
+ var firstVisibleItemIndex: Int = 0
+
+ /// Unique item id of the first visible item on screen
+ var firstVisibleItemId: any Hashable = EndlessScrollView.DEFAULT_ITEM_ID
+
+ /// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view
+ var firstVisibleItemOffset: CGFloat = -100
+
+ /// Index of the last visible item on screen
+ var lastVisibleItemIndex: Int {
+ visibleItems.last?.index ?? 0
+ }
+
+ /// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
+ var itemsCanCoverScreen: Bool = false
+
+ /// Whether there is a non-animated scroll to item in progress or not
+ var isScrolling: Bool = false
+ /// Whether there is an animated scroll to item in progress or not
+ var isAnimatedScrolling: Bool = false
+
+ override init() {
+ super.init()
+ }
+ }
+
+ class VisibleItem {
+ let index: Int
+ let item: ScrollItem
+ let view: UIView
+ var offset: CGFloat
+
+ init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) {
+ self.index = index
+ self.item = item
+ self.view = view
+ self.offset = offset
+ }
+ }
+
+ class ContentHeight {
+ /// After that you should see overscroll effect. When scroll positon is far from
+ /// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example:
+ /// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height
+ var topOffsetY: CGFloat = 0
+ var bottomOffsetY: CGFloat = 0
+
+ var virtualScrollOffsetY: CGFloat = 0
+
+ /// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView
+ var overscrolledTop: CGFloat = 0
+
+ /// Adds content padding to bottom and top
+ var inset: CGFloat = 100
+
+ /// Estimated height of the contents of scroll view
+ var height: CGFloat {
+ get { bottomOffsetY - topOffsetY }
+ }
+
+ /// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar
+ var virtualOverscrolledHeight: CGFloat {
+ get {
+ bottomOffsetY - topOffsetY + overscrolledTop - inset * 2
+ }
+ }
+
+ func update(
+ _ contentOffset: CGPoint,
+ _ listState: ListState,
+ _ averageItemHeight: CGFloat,
+ _ updateStaleHeight: Bool
+ ) {
+ let lastVisible = listState.visibleItems.last
+ let firstVisible = listState.visibleItems.first
+ guard let last = lastVisible, let first = firstVisible else {
+ topOffsetY = contentOffset.y
+ bottomOffsetY = contentOffset.y
+ virtualScrollOffsetY = 0
+ overscrolledTop = 0
+ return
+ }
+ topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset
+ bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset
+ virtualScrollOffsetY = contentOffset.y - topOffsetY
+ overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0)
+ }
+ }
+
+ var topY: CGFloat {
+ get { contentOffset.y }
+ }
+
+ var bottomY: CGFloat {
+ get { contentOffset.y + bounds.height }
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ if contentSize.height == 0 {
+ setup()
+ }
+ let newScreenHeight = bounds.height
+ if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 {
+ contentOffset.y += oldScreenHeight - newScreenHeight
+ scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom)
+ }
+ oldScreenHeight = newScreenHeight
+ adaptItems(listState.items, false)
+ if let index = scrollToItemIndexDelayed {
+ scrollToItem(index)
+ scrollToItemIndexDelayed = nil
+ }
+ }
+
+ private func setup() {
+ contentSize = CGSizeMake(frame.size.width, initialOffset * 2)
+ prevProcessedOffset = initialOffset
+ contentOffset = CGPointMake(0, initialOffset)
+
+ showsVerticalScrollIndicator = false
+ scrollBarView.showsHorizontalScrollIndicator = false
+ panGestureRecognizer.delegate = self
+ addGestureRecognizer(scrollBarView.panGestureRecognizer)
+ superview!.addSubview(scrollBarView)
+ }
+
+ func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
+ if !Thread.isMainThread {
+ logger.error("Use main thread to update items")
+ return
+ }
+ if bounds.height == 0 {
+ self.listState.items = items
+ // this function requires to have valid bounds and it will be called again once it has them
+ return
+ }
+ adaptItems(items, forceReloadVisible)
+ snapToContent(animated: false)
+ }
+
+ /// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes
+ private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) {
+ let start = Date.now
+ // special case when everything was removed
+ if items.isEmpty {
+ listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
+ listState.visibleItems = []
+ listState.itemsCanCoverScreen = false
+ listState.firstVisibleItemId = EndlessScrollView.DEFAULT_ITEM_ID
+ listState.firstVisibleItemIndex = 0
+ listState.firstVisibleItemOffset = -insetTop
+
+ estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
+ scrollBarView.contentSize = .zero
+ scrollBarView.contentOffset = .zero
+
+ prevProcessedOffset = contentOffset.y
+ // this check is just to prevent didSet listener from firing on the same empty array, no use for this
+ if !self.listState.items.isEmpty {
+ self.listState.items = items
+ }
+ return
+ }
+
+ let contentOffsetY = overridenOffset ?? contentOffset.y
+
+ var oldVisible = listState.visibleItems
+ var newVisible: [VisibleItem] = []
+ var visibleItemsHeight: CGFloat = 0
+ let offsetsDiff = contentOffsetY - prevProcessedOffset
+
+ var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
+
+ var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset
+ var alreadyChangedIndexWhileScrolling = false
+ var allowOneMore = false
+ var nextOffsetY: CGFloat = 0
+ var i = shouldBeFirstVisible
+ // building list of visible items starting from the first one that should be visible
+ while i >= 0 && i < items.count {
+ let item = items[i]
+ let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
+ let visible: VisibleItem?
+ if let visibleIndex {
+ let v = oldVisible.remove(at: visibleIndex)
+ if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
+ let wasHeight = v.view.bounds.height
+ updateCell(v.view, i, items)
+ if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i {
+ v.view.frame.origin.y -= v.view.bounds.height - wasHeight
+ }
+ }
+ visible = v
+ } else {
+ visible = nil
+ }
+ if shouldBeFirstVisible == i {
+ if let vis = visible {
+
+ if // there is auto scroll in progress and the first item has a higher offset than bottom part
+ // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
+ // re-make the first visible item
+ (listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
+ // the fist visible item previously is hidden now, remove it and move on
+ !isVisible(vis.view) {
+ let newIndex: Int
+ if listState.isAnimatedScrolling {
+ // skip many items to make the scrolling take less time
+ var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
+ // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
+ alreadyChangedIndexWhileScrolling = true
+
+ indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff
+ newIndex = max(0, min(items.count - 1, i + indexDiff))
+ // offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore
+ wasFirstVisibleItemOffset = 0
+ } else {
+ // don't skip multiple items if it's manual scrolling gesture
+ newIndex = i + (offsetsDiff <= 0 ? 1 : -1)
+ }
+ shouldBeFirstVisible = newIndex
+ i = newIndex
+
+ cellsToReuse.append(vis.view)
+ hideAndRemoveFromSuperviewIfNeeded(vis.view)
+ continue
+ }
+ }
+ let vis: VisibleItem
+ if let visible {
+ vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
+ } else {
+ let cell = createCell(i, items, &cellsToReuse)!
+ cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height
+ vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
+ }
+ if vis.view.superview == nil {
+ addSubview(vis.view)
+ }
+ newVisible.append(vis)
+ visibleItemsHeight += vis.view.frame.height
+ nextOffsetY = vis.view.frame.origin.y
+ } else {
+ let vis: VisibleItem
+ if let visible {
+ vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
+ nextOffsetY -= vis.view.frame.height
+ vis.view.frame.origin.y = nextOffsetY
+ } else {
+ let cell = createCell(i, items, &cellsToReuse)!
+ nextOffsetY -= cell.frame.height
+ cell.frame.origin.y = nextOffsetY
+ vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
+ }
+ if vis.view.superview == nil {
+ addSubview(vis.view)
+ }
+ newVisible.append(vis)
+ visibleItemsHeight += vis.view.frame.height
+ }
+ if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
+ break
+ } else if abs(nextOffsetY) < contentOffsetY {
+ allowOneMore = false
+ }
+ i += 1
+ }
+ if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 {
+ var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height
+ let index = firstVisible.index
+ for i in stride(from: index - 1, through: 0, by: -1) {
+ let item = items[i]
+ let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
+ let vis: VisibleItem
+ if let visibleIndex {
+ let visible = oldVisible.remove(at: visibleIndex)
+ visible.view.frame.origin.y = offset
+ vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
+ } else {
+ let cell = createCell(i, items, &cellsToReuse)!
+ cell.frame.origin.y = offset
+ vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
+ }
+ if vis.view.superview == nil {
+ addSubview(vis.view)
+ }
+ offset += vis.view.frame.height
+ newVisible.insert(vis, at: 0)
+ visibleItemsHeight += vis.view.frame.height
+ if offset >= contentOffsetY + bounds.height {
+ break
+ }
+ }
+ }
+
+ // removing already unneeded visible items
+ oldVisible.forEach { vis in
+ cellsToReuse.append(vis.view)
+ hideAndRemoveFromSuperviewIfNeeded(vis.view)
+ }
+ let itemsCountChanged = listState.items.count != items.count
+ prevProcessedOffset = contentOffsetY
+
+ listState.visibleItems = newVisible
+ // bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
+ // For visible items to preserve offset after adding more items having such height is enough
+ listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
+
+ listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView.DEFAULT_ITEM_ID
+ listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
+ listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
+ // updating the items with the last step in order to call listener with fully updated state
+ listState.items = items
+
+ estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
+ scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
+ scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY)
+ scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height)
+
+ if debug {
+ println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))")
+ }
+ }
+
+ func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) {
+ listState.firstVisibleItemIndex = index
+ listState.firstVisibleItemId = id
+ listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset
+ }
+
+ func scrollToItem(_ index: Int, top: Bool = true) {
+ if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
+ return
+ }
+ if bounds.height == 0 || contentSize.height == 0 {
+ scrollToItemIndexDelayed = index
+ return
+ }
+ listState.isScrolling = true
+ defer {
+ listState.isScrolling = false
+ }
+
+ // just a faster way to set top item as requested index
+ listState.firstVisibleItemIndex = index
+ listState.firstVisibleItemId = listState.items[index].id
+ listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom
+ scrollBarView.flashScrollIndicators()
+ adaptItems(listState.items, false)
+
+ var adjustedOffset = self.contentOffset.y
+ var i = 0
+
+ var upPrev = index > listState.firstVisibleItemIndex
+ //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
+ //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
+
+ var stepSlowdownMultiplier: CGFloat = 1
+ while i < 200 {
+ let up = index > listState.firstVisibleItemIndex
+ if upPrev != up {
+ stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
+ upPrev = up
+ }
+
+ // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
+ let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
+ let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
+
+ let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
+ adjustedOffset += offsetToScroll
+ if let item = listState.visibleItems.first(where: { $0.index == index }) {
+ let y = if top {
+ min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
+ } else {
+ max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
+ }
+ setContentOffset(CGPointMake(contentOffset.x, y), animated: false)
+ scrollBarView.flashScrollIndicators()
+ break
+ }
+ contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
+ adaptItems(listState.items, false)
+ snapToContent(animated: false)
+ i += 1
+ }
+ adaptItems(listState.items, false)
+ snapToContent(animated: false)
+ estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
+ }
+
+ func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
+ if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
+ return
+ }
+ listState.isAnimatedScrolling = true
+ defer {
+ listState.isAnimatedScrolling = false
+ }
+ var adjustedOffset = self.contentOffset.y
+ var i = 0
+
+ var upPrev = index > listState.firstVisibleItemIndex
+ //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
+ //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
+
+ var stepSlowdownMultiplier: CGFloat = 1
+ while i < 200 {
+ let up = index > listState.firstVisibleItemIndex
+ if upPrev != up {
+ stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
+ upPrev = up
+ }
+
+ // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
+ let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
+ let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
+
+ //println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)")
+
+ let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
+ adjustedOffset += offsetToScroll
+ if let item = listState.visibleItems.first(where: { $0.index == index }) {
+ let y = if top {
+ min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
+ } else {
+ max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
+ }
+ setContentOffset(CGPointMake(contentOffset.x, y), animated: true)
+ scrollBarView.flashScrollIndicators()
+ break
+ }
+ contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
+
+ // skipping unneded relayout if this offset is already processed
+ if prevProcessedOffset - contentOffset.y != 0 {
+ adaptItems(listState.items, false)
+ snapToContent(animated: false)
+ }
+ // let UI time to update to see the animated position change
+ await MainActor.run {}
+
+ i += 1
+ }
+ estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
+ }
+
+ func scrollToBottom() {
+ scrollToItem(0, top: false)
+ }
+
+ func scrollToBottomAnimated() {
+ Task {
+ await scrollToItemAnimated(0, top: false)
+ }
+ }
+
+ func scroll(by: CGFloat, animated: Bool = true) {
+ setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated)
+ }
+
+ func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
+ if !listState.items.isEmpty {
+ scrollToBottomAnimated()
+ }
+ return false
+ }
+
+ private func snapToContent(animated: Bool) {
+ let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
+ if topY < estimatedContentHeight.topOffsetY - topBlankSpace {
+ setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated)
+ } else if bottomY > estimatedContentHeight.bottomOffsetY {
+ setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated)
+ }
+ }
+
+ func offsetToBottom(_ view: UIView) -> CGFloat {
+ bottomY - (view.frame.origin.y + view.frame.height)
+ }
+
+ /// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of
+ /// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view
+ func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) {
+ if view.isHidden {
+ // already passed this function
+ return
+ }
+ (view as? ReusableView)?.prepareForReuse()
+ view.isHidden = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ if view.isHidden { view.removeFromSuperview() }
+ }
+ }
+
+ /// Synchronizing both scrollViews
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+ true
+ }
+
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
+ if !decelerate {
+ snapToContent(animated: true)
+ }
+ }
+
+ override var contentOffset: CGPoint {
+ get { super.contentOffset }
+ set {
+ var newOffset = newValue
+ let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
+ if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y {
+ if !isDecelerating {
+ newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8)
+ } else {
+ DispatchQueue.main.async {
+ self.setContentOffset(newValue, animated: false)
+ self.snapToContent(animated: true)
+ }
+ }
+ } else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y {
+ if !isDecelerating {
+ newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8)
+ } else {
+ DispatchQueue.main.async {
+ self.setContentOffset(newValue, animated: false)
+ self.snapToContent(animated: true)
+ }
+ }
+ }
+ super.contentOffset = newOffset
+ }
+ }
+
+ private func stopScrolling() {
+ let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) {
+ estimatedContentHeight.topOffsetY
+ } else {
+ estimatedContentHeight.bottomOffsetY - bounds.height
+ }
+ setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false)
+ }
+
+ func isVisible(_ view: UIView) -> Bool {
+ if view.superview == nil {
+ return false
+ }
+ return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height))
+ }
+}
+
+private func println(_ text: String) {
+ print("\(Date.now.timeIntervalSince1970): \(text)")
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
index 189ab95494..7cd543af10 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -78,7 +78,12 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count
Section {
if creatingGroup {
- groupPreferencesButton($groupInfo, true)
+ GroupPreferencesButton(
+ groupInfo: $groupInfo,
+ preferences: groupInfo.fullGroupPreferences,
+ currentPreferences: groupInfo.fullGroupPreferences,
+ creatingGroup: true
+ )
}
rolePicker()
inviteMembersButton()
@@ -105,8 +110,10 @@ struct AddGroupMembersViewCommon: View {
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
- ForEach(members) { contact in
- contactCheckView(contact)
+ ForEach(members + [dummyContact]) { contact in
+ if contact.contactId != dummyContact.contactId {
+ contactCheckView(contact)
+ }
}
}
}
@@ -130,12 +137,21 @@ struct AddGroupMembersViewCommon: View {
.modifier(ThemedBackground(grouped: true))
}
+ // Resolves keyboard losing focus bug in iOS16 and iOS17,
+ // when there are no items inside `ForEach(memebers)` loop
+ private let dummyContact: Contact = {
+ var dummy = Contact.sampleData
+ dummy.contactId = -1
+ return dummy
+ }()
+
private func inviteMembersButton() -> some View {
- Button {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
+ return Button {
inviteMembers()
} label: {
HStack {
- Text("Invite to group")
+ Text(label)
Image(systemName: "checkmark")
}
}
@@ -159,10 +175,8 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
- ForEach(GroupMemberRole.allCases) { role in
- if role <= groupInfo.membership.memberRole && role != .author {
- Text(role.text)
- }
+ ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
+ Text(role.text)
}
}
.frame(height: 36)
@@ -221,6 +235,7 @@ func searchFieldView(text: Binding, focussed: FocusState.Binding,
.focused(focussed)
.foregroundColor(onBackgroundColor)
.frame(maxWidth: .infinity)
+ .autocorrectionDisabled(true)
Image(systemName: "xmark.circle.fill")
.resizable()
.scaledToFit()
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 9385633060..15749b0761 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -18,8 +18,10 @@ struct GroupChatInfoView: View {
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
var onSearch: () -> Void
+ @State var localAlias: String
+ @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil
- @State private var groupLink: String?
+ @State private var groupLink: CreatedConnLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@@ -27,6 +29,7 @@ struct GroupChatInfoView: View {
@State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
+ @State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@@ -67,94 +70,112 @@ struct GroupChatInfoView: View {
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
- List {
- groupInfoHeader()
- .listRowBackground(Color.clear)
- .padding(.bottom, 18)
-
- infoActionButtons()
- .padding(.horizontal)
- .frame(maxWidth: .infinity)
- .frame(height: infoViewActionButtonHeight)
- .listRowBackground(Color.clear)
- .listRowSeparator(.hidden)
- .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
-
- Section {
- if groupInfo.canEdit {
- editGroupButton()
- }
- if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
- addOrEditWelcomeMessage()
- }
- groupPreferencesButton($groupInfo)
- if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
- sendReceiptsOption()
- } else {
- sendReceiptsOptionDisabled()
- }
- NavigationLink {
- ChatWallpaperEditorSheet(chat: chat)
- } label: {
- Label("Chat theme", systemImage: "photo")
- }
- } header: {
- Text("")
- } footer: {
- Text("Only group owners can change group preferences.")
- .foregroundColor(theme.colors.secondary)
- }
-
- Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
- if groupInfo.canAddMembers {
- groupLinkButton()
- if (chat.chatInfo.incognito) {
- Label("Invite members", systemImage: "plus")
- .foregroundColor(Color(uiColor: .tertiaryLabel))
- .onTapGesture { alert = .cantInviteIncognitoAlert }
- } else {
- addMembersButton()
+ ZStack {
+ List {
+ groupInfoHeader()
+ .listRowBackground(Color.clear)
+
+ localAliasTextEdit()
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .padding(.bottom, 18)
+
+ infoActionButtons()
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity)
+ .frame(height: infoViewActionButtonHeight)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+
+ Section {
+ if groupInfo.isOwner && groupInfo.businessChat == nil {
+ editGroupButton()
}
+ if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
+ addOrEditWelcomeMessage()
+ }
+ GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
+ if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
+ sendReceiptsOption()
+ } else {
+ sendReceiptsOptionDisabled()
+ }
+
+ NavigationLink {
+ ChatWallpaperEditorSheet(chat: chat)
+ } label: {
+ Label("Chat theme", systemImage: "photo")
+ }
+ } header: {
+ Text("")
+ } footer: {
+ let label: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Only group owners can change group preferences."
+ : "Only chat owners can change preferences."
+ )
+ Text(label)
+ .foregroundColor(theme.colors.secondary)
}
- if members.count > 8 {
+
+ Section {
+ ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
+ } footer: {
+ Text("Delete chat messages from your device.")
+ }
+
+ Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
+ if groupInfo.canAddMembers {
+ if groupInfo.businessChat == nil {
+ groupLinkButton()
+ }
+ if (chat.chatInfo.incognito) {
+ Label("Invite members", systemImage: "plus")
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .onTapGesture { alert = .cantInviteIncognitoAlert }
+ } else {
+ addMembersButton()
+ }
+ }
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
+ let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
+ let filteredMembers = s == ""
+ ? members
+ : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
+ MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
+ ForEach(filteredMembers) { member in
+ MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert)
+ }
}
- let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
- let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
- MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
- ForEach(filteredMembers) { member in
- ZStack {
- NavigationLink {
- memberInfoView(member)
- } label: {
- EmptyView()
- }
- .opacity(0)
- MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
+
+ Section {
+ clearChatButton()
+ if groupInfo.canDelete {
+ deleteGroupButton()
+ }
+ if groupInfo.membership.memberCurrent {
+ leaveGroupButton()
+ }
+ }
+
+ if developerTools {
+ Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
+ infoRow("Local name", chat.chatInfo.localDisplayName)
+ infoRow("Database ID", "\(chat.chatInfo.apiId)")
}
}
}
-
- Section {
- clearChatButton()
- if groupInfo.canDelete {
- deleteGroupButton()
- }
- if groupInfo.membership.memberCurrent {
- leaveGroupButton()
- }
- }
-
- if developerTools {
- Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
- infoRow("Local name", chat.chatInfo.localDisplayName)
- infoRow("Database ID", "\(chat.chatInfo.apiId)")
- }
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarHidden(true)
+ .disabled(progressIndicator)
+ .opacity(progressIndicator ? 0.6 : 1)
+
+ if progressIndicator {
+ ProgressView().scaleEffect(2)
}
}
- .modifier(ThemedBackground(grouped: true))
- .navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
@@ -193,7 +214,7 @@ struct GroupChatInfoView: View {
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
- Text(cInfo.displayName)
+ Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(4)
@@ -208,6 +229,37 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
+ private func localAliasTextEdit() -> some View {
+ TextField("Set chat name…", text: $localAlias)
+ .disableAutocorrection(true)
+ .focused($aliasTextFieldFocused)
+ .submitLabel(.done)
+ .onChange(of: aliasTextFieldFocused) { focused in
+ if !focused {
+ setGroupAlias()
+ }
+ }
+ .onSubmit {
+ setGroupAlias()
+ }
+ .multilineTextAlignment(.center)
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ private func setGroupAlias() {
+ Task {
+ do {
+ if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
+ await MainActor.run {
+ chatModel.updateGroup(gInfo)
+ }
+ }
+ } catch {
+ logger.error("setGroupAlias error: \(responseError(error))")
+ }
+ }
+ }
+
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
@@ -216,7 +268,9 @@ struct GroupChatInfoView: View {
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
- muteButton(width: buttonWidth)
+ if let nextNtfMode = chat.chatInfo.nextNtfMode {
+ muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
+ }
}
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -230,9 +284,9 @@ struct GroupChatInfoView: View {
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
- @ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
- if chat.chatInfo.incognito {
- ZStack {
+ private func addMembersActionButton(width: CGFloat) -> some View {
+ ZStack {
+ if chat.chatInfo.incognito {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
@@ -244,10 +298,7 @@ struct GroupChatInfoView: View {
}
.frame(width: 1, height: 1)
.hidden()
- }
- .disabled(!groupInfo.ready)
- } else {
- ZStack {
+ } else {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
@@ -260,26 +311,31 @@ struct GroupChatInfoView: View {
.frame(width: 1, height: 1)
.hidden()
}
- .disabled(!groupInfo.ready)
}
+ .disabled(!groupInfo.ready)
}
- private func muteButton(width: CGFloat) -> some View {
- InfoViewButton(
- image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
- title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: true))",
width: width
) {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View {
- NavigationLink {
+ let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
+ case .customer: "Add team members"
+ case .business: "Add friends"
+ case .none: "Invite members"
+ }
+ return NavigationLink {
addMembersDestinationView()
} label: {
- Label("Invite members", systemImage: "plus")
+ Label(label, systemImage: "plus")
}
}
@@ -288,16 +344,13 @@ struct GroupChatInfoView: View {
.onAppear {
searchFocussed = false
Task {
- let groupMembers = await apiListMembers(groupInfo.groupId)
- await MainActor.run {
- chatModel.groupMembers = groupMembers.map { GMember.init($0) }
- chatModel.populateGroupMembersIndexes()
- }
+ await chatModel.loadGroupMembers(groupInfo)
}
}
}
private struct MemberRowView: View {
+ var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme
@@ -306,7 +359,7 @@ struct GroupChatInfoView: View {
var body: some View {
let member = groupMember.wrapped
- let v = HStack{
+ let v1 = HStack{
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
// TODO server connection status
@@ -322,7 +375,21 @@ struct GroupChatInfoView: View {
Spacer()
memberInfo(member)
}
-
+
+ let v = ZStack {
+ if user {
+ v1
+ } else {
+ NavigationLink {
+ memberInfoView()
+ } label: {
+ EmptyView()
+ }
+ .opacity(0)
+ v1
+ }
+ }
+
if user {
v
} else if groupInfo.membership.memberRole >= .admin {
@@ -347,6 +414,11 @@ struct GroupChatInfoView: View {
}
}
+ private func memberInfoView() -> some View {
+ GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
+ .navigationBarHidden(false)
+ }
+
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
@@ -418,7 +490,7 @@ struct GroupChatInfoView: View {
}
private var memberVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.baselineOffset(2)
.kerning(-2)
@@ -426,11 +498,6 @@ struct GroupChatInfoView: View {
}
}
- private func memberInfoView(_ groupMember: GMember) -> some View {
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
- .navigationBarHidden(false)
- }
-
private func groupLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
@@ -487,11 +554,12 @@ struct GroupChatInfoView: View {
}
}
- private func deleteGroupButton() -> some View {
+ @ViewBuilder private func deleteGroupButton() -> some View {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
- Label("Delete group", systemImage: "trash")
+ Label(label, systemImage: "trash")
.foregroundColor(Color.red)
}
}
@@ -506,19 +574,21 @@ struct GroupChatInfoView: View {
}
private func leaveGroupButton() -> some View {
- Button(role: .destructive) {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
+ return Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
- Label("Leave group", systemImage: "rectangle.portrait.and.arrow.right")
+ Label(label, systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(Color.red)
}
}
// TODO reuse this and clearChatAlert with ChatInfoView
private func deleteGroupAlert() -> Alert {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
- title: Text("Delete group?"),
- message: deleteGroupAlertMessage(),
+ title: Text(label),
+ message: deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
@@ -537,10 +607,6 @@ struct GroupChatInfoView: View {
)
}
- private func deleteGroupAlertMessage() -> Text {
- groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
- }
-
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -556,9 +622,15 @@ struct GroupChatInfoView: View {
}
private func leaveGroupAlert() -> Alert {
- Alert(
- title: Text("Leave group?"),
- message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
+ let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "You will stop receiving messages from this group. Chat history will be preserved."
+ : "You will stop receiving messages from this chat. Chat history will be preserved."
+ )
+ return Alert(
+ title: Text(titleLabel),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task {
await leaveGroup(chat.chatInfo.apiId)
@@ -602,18 +674,25 @@ struct GroupChatInfoView: View {
}
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
- Alert(
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Member will be removed from group - this cannot be undone!"
+ : "Member will be removed from chat - this cannot be undone!"
+ )
+ return Alert(
title: Text("Remove member?"),
- message: Text("Member will be removed from group - this cannot be undone!"),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
- let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
+ let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ updatedMembers.forEach { updatedMember in
+ _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ }
}
} catch let error {
- logger.error("apiRemoveMember error: \(responseError(error))")
+ logger.error("apiRemoveMembers error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message)
}
@@ -624,26 +703,80 @@ struct GroupChatInfoView: View {
}
}
-func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View {
- NavigationLink {
- GroupPreferencesView(
- groupInfo: groupInfo,
- preferences: groupInfo.wrappedValue.fullGroupPreferences,
- currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
- creatingGroup: creatingGroup
- )
- .navigationBarTitle("Group preferences")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- if creatingGroup {
- Text("Set group preferences")
- } else {
- Label("Group preferences", systemImage: "switch.2")
+func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
+ groupInfo.businessChat == nil ? (
+ groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
+ ) : (
+ groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!")
+ )
+}
+
+struct GroupPreferencesButton: View {
+ @Binding var groupInfo: GroupInfo
+ @State var preferences: FullGroupPreferences
+ @State var currentPreferences: FullGroupPreferences
+ var creatingGroup: Bool = false
+
+ private var label: LocalizedStringKey {
+ groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences"
+ }
+
+ var body: some View {
+ NavigationLink {
+ GroupPreferencesView(
+ groupInfo: $groupInfo,
+ preferences: $preferences,
+ currentPreferences: currentPreferences,
+ creatingGroup: creatingGroup,
+ savePreferences: savePreferences
+ )
+ .navigationBarTitle(label)
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ .onDisappear {
+ let saveText = NSLocalizedString(
+ creatingGroup ? "Save" : "Save and notify group members",
+ comment: "alert button"
+ )
+
+ if groupInfo.fullGroupPreferences != preferences {
+ showAlert(
+ title: NSLocalizedString("Save preferences?", comment: "alert title"),
+ buttonTitle: saveText,
+ buttonAction: { savePreferences() },
+ cancelButton: true
+ )
+ }
+ }
+ } label: {
+ if creatingGroup {
+ Text("Set group preferences")
+ } else {
+ Label(label, systemImage: "switch.2")
+ }
}
}
+
+ private func savePreferences() {
+ Task {
+ do {
+ var gp = groupInfo.groupProfile
+ gp.groupPreferences = toGroupPreferences(preferences)
+ let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
+ await MainActor.run {
+ groupInfo = gInfo
+ ChatModel.shared.updateGroup(gInfo)
+ currentPreferences = preferences
+ }
+ } catch {
+ logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
+ }
+ }
+ }
+
}
+
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
@@ -663,7 +796,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
- onSearch: {}
+ onSearch: {},
+ localAlias: ""
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index 39288e2d52..a11c073a42 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -10,12 +10,14 @@ import SwiftUI
import SimpleXChat
struct GroupLinkView: View {
+ @EnvironmentObject var theme: AppTheme
var groupId: Int64
- @Binding var groupLink: String?
+ @Binding var groupLink: CreatedConnLink?
@Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil
+ @State private var showShortLink = true
@State private var creatingLink = false
@State private var alert: GroupLinkAlert?
@State private var shouldCreate = true
@@ -69,10 +71,10 @@ struct GroupLinkView: View {
}
}
.frame(height: 36)
- SimpleXLinkQRCode(uri: groupLink)
- .id("simplex-qrcode-view-for-\(groupLink)")
+ SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink)
+ .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))")
Button {
- showShareSheet(items: [simplexChatLink(groupLink)])
+ showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
@@ -93,6 +95,10 @@ struct GroupLinkView: View {
.frame(maxWidth: .infinity)
}
}
+ } header: {
+ if let groupLink, groupLink.connShortLink != nil {
+ ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink)
+ }
}
.alert(item: $alert) { alert in
switch alert {
@@ -158,8 +164,8 @@ struct GroupLinkView: View {
struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View {
- @State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
- @State var noGroupLink: String? = nil
+ @State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil)
+ @State var noGroupLink: CreatedConnLink? = nil
return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index ddf3b8e4b9..79ad242366 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -14,10 +14,15 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo
+ @ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
+ @State private var connectionLoaded: Bool = false
+ @State private var knownContactChat: Chat? = nil
+ @State private var knownContact: Contact? = nil
+ @State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@@ -94,128 +99,149 @@ struct GroupMemberInfoView: View {
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
- if member.memberActive {
- Section {
- if let code = connectionCode { verifyCodeButton(code) }
- if let connStats = connectionStats,
- connStats.ratchetSyncAllowed {
- synchronizeConnectionButton()
- }
- // } else if developerTools {
- // synchronizeConnectionButtonForce()
- // }
- }
- }
+ if connectionLoaded {
- if let contactLink = member.contactLink {
- Section {
- SimpleXLinkQRCode(uri: contactLink)
- Button {
- showShareSheet(items: [simplexChatLink(contactLink)])
- } label: {
- Label("Share address", systemImage: "square.and.arrow.up")
+ if member.memberActive {
+ Section {
+ if let code = connectionCode { verifyCodeButton(code) }
+ if let connStats = connectionStats,
+ connStats.ratchetSyncAllowed {
+ synchronizeConnectionButton()
+ }
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
}
- if let contactId = member.memberContactId {
- if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
+ }
+
+ if let contactLink = member.contactLink {
+ Section {
+ SimpleXLinkQRCode(uri: contactLink)
+ Button {
+ showShareSheet(items: [simplexChatLink(contactLink)])
+ } label: {
+ Label("Share address", systemImage: "square.and.arrow.up")
+ }
+ if member.memberContactId != nil {
+ if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
+ connectViaAddressButton(contactLink)
+ }
+ } else {
connectViaAddressButton(contactLink)
}
- } else {
- connectViaAddressButton(contactLink)
+ } header: {
+ Text("Address")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
+ .foregroundColor(theme.colors.secondary)
}
- } header: {
- Text("Address")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
- .foregroundColor(theme.colors.secondary)
}
- }
- Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
- infoRow("Group", groupInfo.displayName)
+ Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
+ infoRow(label, groupInfo.displayName)
- if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
- Picker("Change role", selection: $newRole) {
- ForEach(roles) { role in
- Text(role.text)
+ if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
+ Picker("Change role", selection: $newRole) {
+ ForEach(roles) { role in
+ Text(role.text)
+ }
}
+ .frame(height: 36)
+ } else {
+ infoRow("Role", member.memberRole.text)
}
- .frame(height: 36)
- } else {
- infoRow("Role", member.memberRole.text)
}
- }
- if let connStats = connectionStats {
- Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
- // TODO network connection status
- Button("Change receiving address") {
- alert = .switchAddressAlert
- }
- .disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
- || connStats.ratchetSyncSendProhibited
- )
- if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
- Button("Abort changing address") {
- alert = .abortSwitchAddressAlert
+ if let connStats = connectionStats {
+ Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
+ // TODO network connection status
+ Button("Change receiving address") {
+ alert = .switchAddressAlert
}
.disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
- || connStats.ratchetSyncSendProhibited
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
+ || !member.sendMsgEnabled
)
+ if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
+ Button("Abort changing address") {
+ alert = .abortSwitchAddressAlert
+ }
+ .disabled(
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ || !member.sendMsgEnabled
+ )
+ }
+ smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
+ smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
- }
- if groupInfo.membership.memberRole >= .admin {
- adminDestructiveSection(member)
- } else {
- nonAdminBlockSection(member)
- }
+ if groupInfo.membership.memberRole >= .admin {
+ adminDestructiveSection(member)
+ } else {
+ nonAdminBlockSection(member)
+ }
- if developerTools {
- Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
- infoRow("Local name", member.localDisplayName)
- infoRow("Database ID", "\(member.groupMemberId)")
- if let conn = member.activeConn {
- let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
- infoRow("Connection", connLevelDesc)
- }
- Button ("Debug delivery") {
- Task {
- do {
- let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
- await MainActor.run { alert = .queueInfo(info: info) }
- } catch let e {
- logger.error("apiContactQueueInfo error: \(responseError(e))")
- let a = getErrorAlert(e, "Error")
- await MainActor.run { alert = .error(title: a.title, error: a.message) }
+ if developerTools {
+ Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
+ infoRow("Local name", member.localDisplayName)
+ infoRow("Database ID", "\(member.groupMemberId)")
+ if let conn = member.activeConn {
+ let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
+ infoRow("Connection", connLevelDesc)
+ }
+ Button ("Debug delivery") {
+ Task {
+ do {
+ let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
+ await MainActor.run { alert = .queueInfo(info: info) }
+ } catch let e {
+ logger.error("apiContactQueueInfo error: \(responseError(e))")
+ let a = getErrorAlert(e, "Error")
+ await MainActor.run { alert = .error(title: a.title, error: a.message) }
+ }
}
}
}
}
+
}
}
.navigationBarHidden(true)
- .onAppear {
+ .task {
if #unavailable(iOS 16) {
// this condition prevents re-setting picker
if !justOpened { return }
}
justOpened = false
- DispatchQueue.main.async {
- newRole = member.memberRole
- do {
- let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
- let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
+ newRole = member.memberRole
+ do {
+ let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
+ let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
+ await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
+ connectionLoaded = true
+ }
+ } catch let error {
+ await MainActor.run {
+ connectionLoaded = true
+ }
+ logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ }
+ if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
+ knownContactChat = contactChat
+ knownContact = contact
+ do {
+ let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
+ await MainActor.run {
+ knownContactConnectionStats = stats
+ }
} catch let error {
- logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ logger.error("apiContactInfo error: \(responseError(error))")
}
}
}
@@ -251,6 +277,11 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2)
}
}
+ .onChange(of: chat.chatInfo) { c in
+ if case let .group(gI) = chat.chatInfo {
+ groupInfo = gI
+ }
+ }
.modifier(ThemedBackground(grouped: true))
}
@@ -258,15 +289,15 @@ struct GroupMemberInfoView: View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
- if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
+ if let chat = knownContactChat, let contact = knownContact {
knownDirectChatButton(chat, width: buttonWidth)
- AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
- VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
- } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
- createMemberContactButton(width: buttonWidth)
+ } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
+ createMemberContactButton(member, width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
@@ -296,10 +327,15 @@ struct GroupMemberInfoView: View {
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Direct messages between members are prohibited."
+ : "Direct messages between members are prohibited in this chat."
+ )
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
- message: "Direct messages between members are prohibited in this group."
+ message: messageLabel
),
id: "can't message member, direct messages prohibited"
))
@@ -330,41 +366,76 @@ struct GroupMemberInfoView: View {
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
- do {
- let chat = try await apiGetChat(type: .direct, id: contactId)
- chatModel.addChat(chat)
- ItemsModel.shared.loadOpenChat(chat.id) {
- dismissAllSheets(animated: true)
- }
- } catch let error {
- logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
+ ItemsModel.shared.loadOpenChat("@\(contactId)") {
+ dismissAllSheets(animated: true)
}
}
}
}
- func createMemberContactButton(width: CGFloat) -> some View {
- InfoViewButton(image: "message.fill", title: "message", width: width) {
- progressIndicator = true
- Task {
- do {
- let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
- await MainActor.run {
- progressIndicator = false
- chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
- ItemsModel.shared.loadOpenChat(memberContact.id) {
- dismissAllSheets(animated: true)
+ func createMemberContactButton(_ member: GroupMember, width: CGFloat) -> some View {
+ InfoViewButton(
+ image: "message.fill",
+ title: "message",
+ disabledLook:
+ !(
+ member.sendMsgEnabled ||
+ (member.activeConn?.connectionStats?.ratchetSyncAllowed ?? false)
+ ),
+ width: width
+ ) {
+ if member.sendMsgEnabled {
+ progressIndicator = true
+ Task {
+ do {
+ let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
+ await MainActor.run {
+ progressIndicator = false
+ chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
+ ItemsModel.shared.loadOpenChat(memberContact.id) {
+ dismissAllSheets(animated: true)
+ }
+ NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
+ }
+ } catch let error {
+ logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
+ let a = getErrorAlert(error, "Error creating member contact")
+ await MainActor.run {
+ progressIndicator = false
+ alert = .error(title: a.title, error: a.message)
}
- NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
- }
- } catch let error {
- logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
- let a = getErrorAlert(error, "Error creating member contact")
- await MainActor.run {
- progressIndicator = false
- alert = .error(title: a.title, error: a.message)
}
}
+ } else if let connStats = connectionStats {
+ if connStats.ratchetSyncAllowed {
+ alert = .someAlert(alert: SomeAlert(
+ alert: Alert(
+ title: Text("Fix connection?"),
+ message: Text("Connection requires encryption renegotiation."),
+ primaryButton: .default(Text("Fix")) {
+ syncMemberConnection(force: false)
+ },
+ secondaryButton: .cancel()
+ ),
+ id: "can't message member, fix connection"
+ ))
+ } else if connStats.ratchetSyncInProgress {
+ alert = .someAlert(alert: SomeAlert(
+ alert: mkAlert(
+ title: "Can't message member",
+ message: "Encryption renegotiation in progress."
+ ),
+ id: "can't message member, encryption renegotiation in progress"
+ ))
+ } else {
+ alert = .someAlert(alert: SomeAlert(
+ alert: mkAlert(
+ title: "Can't message member",
+ message: "Connection not ready."
+ ),
+ id: "can't message member, connection not ready"
+ ))
+ }
}
}
}
@@ -379,7 +450,7 @@ struct GroupMemberInfoView: View {
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(mem.displayName)
.font(.largeTitle)
)
@@ -528,19 +599,26 @@ struct GroupMemberInfoView: View {
}
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
- Alert(
+ let label: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Member will be removed from group - this cannot be undone!"
+ : "Member will be removed from chat - this cannot be undone!"
+ )
+ return Alert(
title: Text("Remove member?"),
- message: Text("Member will be removed from group - this cannot be undone!"),
+ message: Text(label),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
- let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
+ let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ updatedMembers.forEach { updatedMember in
+ _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ }
dismiss()
}
} catch let error {
- logger.error("apiRemoveMember error: \(responseError(error))")
+ logger.error("apiRemoveMembers error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message)
}
@@ -553,18 +631,28 @@ struct GroupMemberInfoView: View {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Change member role?"),
- message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
+ message: (
+ mem.memberCurrent
+ ? (
+ groupInfo.businessChat == nil
+ ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
+ : Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
+ )
+ : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
+ ),
primaryButton: .default(Text("Change")) {
Task {
do {
- let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
+ let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole)
await MainActor.run {
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ updatedMembers.forEach { updatedMember in
+ _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ }
}
-
+
} catch let error {
newRole = mem.memberRole
- logger.error("apiMemberRole error: \(responseError(error))")
+ logger.error("apiMembersRole error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing role")
alert = .error(title: a.title, error: a.message)
}
@@ -716,12 +804,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
Task {
do {
- let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
+ let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked)
await MainActor.run {
- _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
+ updatedMembers.forEach { updatedMember in
+ _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
+ }
}
} catch let error {
- logger.error("apiBlockMemberForAll error: \(responseError(error))")
+ logger.error("apiBlockMembersForAll error: \(responseError(error))")
}
}
}
@@ -730,6 +820,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
+ chat: Chat.sampleData,
groupMember: GMember.sampleData
)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
new file mode 100644
index 0000000000..9bb4a0cc35
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
@@ -0,0 +1,249 @@
+//
+// GroupMentions.swift
+// SimpleX (iOS)
+//
+// Created by Diogo Cunha on 30/01/2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let MENTION_START: Character = "@"
+let QUOTE: Character = "'"
+let MEMBER_ROW_SIZE: CGFloat = 60
+let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
+
+struct GroupMentionsView: View {
+ @EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ @Binding var composeState: ComposeState
+ @Binding var selectedRange: NSRange
+ @Binding var keyboardVisible: Bool
+
+ @State private var isVisible = false
+ @State private var currentMessage: String = ""
+ @State private var mentionName: String = ""
+ @State private var mentionRange: NSRange?
+ @State private var mentionMemberId: String?
+ @State private var sortedMembers: [GMember] = []
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ if isVisible {
+ let filtered = filteredMembers()
+ if filtered.count > 0 {
+ Color.white.opacity(0.01)
+ .edgesIgnoringSafeArea(.all)
+ .onTapGesture {
+ isVisible = false
+ }
+ VStack(spacing: 0) {
+ Spacer()
+ Divider()
+ let scroll = ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
+ let mentioned = mentionMemberId == member.wrapped.memberId
+ let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
+ ZStack(alignment: .bottom) {
+ memberRowView(member.wrapped, mentioned)
+ .contentShape(Rectangle())
+ .disabled(disabled)
+ .opacity(disabled ? 0.6 : 1)
+ .onTapGesture {
+ memberSelected(member)
+ }
+ .padding(.horizontal)
+ .frame(height: MEMBER_ROW_SIZE)
+
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+ }
+ }
+ }
+ }
+ .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
+ .background(Color(UIColor.systemBackground))
+
+ if #available(iOS 16.0, *) {
+ scroll.scrollDismissesKeyboard(.never)
+ } else {
+ scroll
+ }
+ }
+ }
+ }
+ }
+ .onChange(of: composeState.parsedMessage) { parsedMsg in
+ currentMessage = composeState.message
+ messageChanged(currentMessage, parsedMsg, selectedRange)
+ }
+ .onChange(of: selectedRange) { r in
+ // This condition is needed to prevent messageChanged called twice,
+ // because composeState.formattedText triggers later when message changes.
+ // The condition is only true if position changed without text change
+ if currentMessage == composeState.message {
+ messageChanged(currentMessage, composeState.parsedMessage, r)
+ }
+ }
+ .onAppear {
+ currentMessage = composeState.message
+ }
+ }
+
+ private func filteredMembers() -> [GMember] {
+ let s = mentionName.lowercased()
+ return s.isEmpty
+ ? sortedMembers
+ : sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
+ }
+
+ private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
+ removeUnusedMentions(parsedMsg)
+ if let (ft, r) = selectedMarkdown(parsedMsg, range) {
+ switch ft.format {
+ case let .mention(name):
+ isVisible = true
+ mentionName = name
+ mentionRange = r
+ mentionMemberId = composeState.mentions[name]?.memberId
+ if !m.membersLoaded {
+ Task {
+ await m.loadGroupMembers(groupInfo)
+ sortMembers()
+ }
+ }
+ return
+ case .none: () //
+ let pos = range.location
+ if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" {
+ let prevChar = getCharacter(msg, pos - 2)?.char
+ if prevChar == nil || prevChar == " " || prevChar == "\n" {
+ isVisible = true
+ mentionName = ""
+ mentionRange = atRange
+ mentionMemberId = nil
+ Task {
+ await m.loadGroupMembers(groupInfo)
+ sortMembers()
+ }
+ return
+ }
+ }
+ default: ()
+ }
+ }
+ closeMemberList()
+ }
+
+ private func sortMembers() {
+ sortedMembers = m.groupMembers.filter({ m in
+ let status = m.wrapped.memberStatus
+ return status != .memLeft && status != .memRemoved && status != .memInvited
+ })
+ .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
+ }
+
+ private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
+ let usedMentions: Set = Set(parsedMsg.compactMap { ft in
+ if case let .mention(name) = ft.format { name } else { nil }
+ })
+ if usedMentions.count < composeState.mentions.count {
+ composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) }))
+ }
+ }
+
+ private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
+ if pos < 0 || pos >= s.count { return nil }
+ let r = NSRange(location: pos, length: 1)
+ return if let range = Range(r, in: s) {
+ (s[range], r)
+ } else {
+ nil
+ }
+ }
+
+ private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? {
+ if parsedMsg.isEmpty { return nil }
+ var i = 0
+ var pos: Int = 0
+ while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location {
+ pos += parsedMsg[i].text.count
+ i += 1
+ }
+ // the second condition will be true when two markdowns are selected
+ return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count
+ ? nil
+ : (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count))
+ }
+
+ private func memberSelected(_ member: GMember) {
+ if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId {
+ addMemberMention(member, range)
+ }
+ }
+
+ private func addMemberMention(_ member: GMember, _ r: NSRange) {
+ guard let range = Range(r, in: composeState.message) else { return }
+ var mentions = composeState.mentions
+ var newName: String
+ if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) {
+ newName = mm.key
+ } else {
+ newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName)
+ }
+ mentions[newName] = CIMention(groupMember: member.wrapped)
+ var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true
+ ? "@'\(newName)'"
+ : "@\(newName)"
+ var newPos = r.location + msgMention.count
+ let newMsgLength = composeState.message.count + msgMention.count - r.length
+ print(newPos)
+ print(newMsgLength)
+ if newPos == newMsgLength {
+ msgMention += " "
+ newPos += 1
+ }
+ composeState = composeState.copy(
+ message: composeState.message.replacingCharacters(in: range, with: msgMention),
+ mentions: mentions
+ )
+ selectedRange = NSRange(location: newPos, length: 0)
+ closeMemberList()
+ keyboardVisible = true
+ }
+
+ private func closeMemberList() {
+ isVisible = false
+ mentionName = ""
+ mentionRange = nil
+ mentionMemberId = nil
+ }
+
+ private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View {
+ return HStack{
+ MemberProfileImage(member, size: 38)
+ .padding(.trailing, 2)
+ VStack(alignment: .leading) {
+ let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
+ (member.verified ? memberVerifiedShield() + t : t)
+ .lineLimit(1)
+ }
+ Spacer()
+ if mentioned {
+ Image(systemName: "checkmark")
+ }
+ }
+
+ func memberVerifiedShield() -> Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .baselineOffset(2)
+ .kerning(-2)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
index 2b0d05375b..ed39c401ce 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -20,9 +20,10 @@ struct GroupPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
- @State var preferences: FullGroupPreferences
- @State var currentPreferences: FullGroupPreferences
+ @Binding var preferences: FullGroupPreferences
+ var currentPreferences: FullGroupPreferences
let creatingGroup: Bool
+ let savePreferences: () -> Void
@State private var showSaveDialogue = false
var body: some View {
@@ -36,9 +37,10 @@ struct GroupPreferencesView: View {
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
featureSection(.files, $preferences.files.enable, $preferences.files.role)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
+ featureSection(.reports, $preferences.reports.enable)
featureSection(.history, $preferences.history.enable)
- if groupInfo.canEdit {
+ if groupInfo.isOwner {
Section {
Button("Reset") { preferences = currentPreferences }
Button(saveText) { savePreferences() }
@@ -68,7 +70,10 @@ struct GroupPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ preferences = currentPreferences
+ dismiss()
+ }
}
}
@@ -77,7 +82,7 @@ struct GroupPreferencesView: View {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
- if groupInfo.canEdit {
+ if groupInfo.isOwner {
let enable = Binding(
get: { enableFeature.wrappedValue == .on },
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
@@ -85,6 +90,7 @@ struct GroupPreferencesView: View {
settingsRow(icon, color: color) {
Toggle(feature.text, isOn: enable)
}
+ .disabled(feature == .reports) // remove in 6.4
if timedOn {
DropdownCustomTimePicker(
selection: $preferences.timedMessages.ttl,
@@ -123,7 +129,7 @@ struct GroupPreferencesView: View {
}
}
} footer: {
- Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
+ Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
.foregroundColor(theme.colors.secondary)
}
.onChange(of: enableFeature.wrappedValue) { enabled in
@@ -132,32 +138,16 @@ struct GroupPreferencesView: View {
}
}
}
-
- private func savePreferences() {
- Task {
- do {
- var gp = groupInfo.groupProfile
- gp.groupPreferences = toGroupPreferences(preferences)
- let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
- await MainActor.run {
- groupInfo = gInfo
- chatModel.updateGroup(gInfo)
- currentPreferences = preferences
- }
- } catch {
- logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
- }
- }
- }
}
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
- preferences: FullGroupPreferences.sampleData,
+ preferences: Binding.constant(FullGroupPreferences.sampleData),
currentPreferences: FullGroupPreferences.sampleData,
- creatingGroup: false
+ creatingGroup: false,
+ savePreferences: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 18cc3f4d80..1617edd11f 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -110,10 +110,13 @@ struct GroupProfileView: View {
}
}
.onChange(of: chosenImage) { image in
- if let image = image {
- groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
- } else {
- groupProfile.image = nil
+ Task {
+ let resized: String? = if let image {
+ await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
+ } else {
+ nil
+ }
+ await MainActor.run { groupProfile.image = resized }
}
}
.onAppear {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
index 9a9002f9dc..97bff70efb 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
@@ -18,12 +18,13 @@ struct GroupWelcomeView: View {
@State private var editMode = true
@FocusState private var keyboardVisible: Bool
@State private var showSaveDialog = false
+ @State private var showSecrets: Set = []
let maxByteCount = 1200
var body: some View {
VStack {
- if groupInfo.canEdit {
+ if groupInfo.isOwner && groupInfo.businessChat == nil {
editorView()
.modifier(BackButton(disabled: Binding.constant(false)) {
if welcomeTextUnchanged() {
@@ -58,7 +59,8 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
- messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
+ let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background))
+ return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift
deleted file mode 100644
index 94d160e1b4..0000000000
--- a/apps/ios/Shared/Views/Chat/ReverseList.swift
+++ /dev/null
@@ -1,316 +0,0 @@
-//
-// ReverseList.swift
-// SimpleX (iOS)
-//
-// Created by Levitating Pineapple on 11/06/2024.
-// Copyright © 2024 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import Combine
-
-/// A List, which displays it's items in reverse order - from bottom to top
-struct ReverseList: UIViewControllerRepresentable {
- let items: Array-
-
- @Binding var scrollState: ReverseListScrollModel
- .State
-
- /// Closure, that returns user interface for a given item
- let content: (Item) -> Content
-
- let loadPage: () -> Void
-
- func makeUIViewController(context: Context) -> Controller {
- Controller(representer: self)
- }
-
- func updateUIViewController(_ controller: Controller, context: Context) {
- if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
- switch destination {
- case .nextPage:
- controller.scrollToNextPage()
- case let .item(id):
- controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
- case .bottom:
- controller.scroll(to: 0, position: .top)
- }
- } else {
- controller.update(items: items)
- }
- }
-
- /// Controller, which hosts SwiftUI cells
- class Controller: UITableViewController {
- private enum Section { case main }
- private let representer: ReverseList
- private var dataSource: UITableViewDiffableDataSource!
- private var itemCount: Int = 0
- private var bag = Set()
-
- init(representer: ReverseList) {
- self.representer = representer
- super.init(style: .plain)
-
- // 1. Style
- tableView = InvertedTableView()
- tableView.separatorStyle = .none
- tableView.transform = .verticalFlip
- tableView.backgroundColor = .clear
-
- // 2. Register cells
- if #available(iOS 16.0, *) {
- tableView.register(
- UITableViewCell.self,
- forCellReuseIdentifier: cellReuseId
- )
- } else {
- tableView.register(
- HostingCell.self,
- forCellReuseIdentifier: cellReuseId
- )
- }
-
- // 3. Configure data source
- self.dataSource = UITableViewDiffableDataSource(
- tableView: tableView
- ) { (tableView, indexPath, item) -> UITableViewCell? in
- if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
- self.representer.loadPage()
- }
- let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
- if #available(iOS 16.0, *) {
- cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
- .margins(.all, 0)
- .minSize(height: 1) // Passing zero will result in system default of 44 points being used
- } else {
- if let cell = cell as? HostingCell {
- cell.set(content: self.representer.content(item), parent: self)
- } else {
- fatalError("Unexpected Cell Type for: \(item)")
- }
- }
- cell.transform = .verticalFlip
- cell.selectionStyle = .none
- cell.backgroundColor = .clear
- return cell
- }
-
- // 4. External state changes will require manual layout updates
- NotificationCenter.default
- .addObserver(
- self,
- selector: #selector(updateLayout),
- name: notificationName,
- object: nil
- )
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) { fatalError() }
-
- deinit { NotificationCenter.default.removeObserver(self) }
-
- @objc private func updateLayout() {
- if #available(iOS 16.0, *) {
- tableView.setNeedsLayout()
- tableView.layoutIfNeeded()
- } else {
- tableView.reloadData()
- }
- }
-
- /// Hides keyboard, when user begins to scroll.
- /// Equivalent to `.scrollDismissesKeyboard(.immediately)`
- override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
- UIApplication.shared
- .sendAction(
- #selector(UIResponder.resignFirstResponder),
- to: nil,
- from: nil,
- for: nil
- )
- NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
- }
-
- override func viewDidAppear(_ animated: Bool) {
- tableView.clipsToBounds = false
- parent?.viewIfLoaded?.clipsToBounds = false
- }
-
- /// Scrolls up
- func scrollToNextPage() {
- tableView.setContentOffset(
- CGPoint(
- x: tableView.contentOffset.x,
- y: tableView.contentOffset.y + tableView.bounds.height
- ),
- animated: true
- )
- Task { representer.scrollState = .atDestination }
- }
-
- /// Scrolls to Item at index path
- /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
- func scroll(to index: Int?, position: UITableView.ScrollPosition) {
- var animated = false
- if #available(iOS 16.0, *) {
- animated = true
- }
- if let index, tableView.numberOfRows(inSection: 0) != 0 {
- tableView.scrollToRow(
- at: IndexPath(row: index, section: 0),
- at: position,
- animated: animated
- )
- } else {
- tableView.setContentOffset(
- CGPoint(x: .zero, y: -InvertedTableView.inset),
- animated: animated
- )
- }
- Task { representer.scrollState = .atDestination }
- }
-
- func update(items: Array
- ) {
- var snapshot = NSDiffableDataSourceSnapshot()
- snapshot.appendSections([.main])
- snapshot.appendItems(items)
- dataSource.defaultRowAnimation = .none
- dataSource.apply(
- snapshot,
- animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
- )
- // Sets content offset on initial load
- if itemCount == 0 {
- tableView.setContentOffset(
- CGPoint(x: 0, y: -InvertedTableView.inset),
- animated: false
- )
- }
- itemCount = items.count
- }
- }
-
- /// `UIHostingConfiguration` back-port for iOS14 and iOS15
- /// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
- private final class HostingCell: UITableViewCell {
- private let hostingController = UIHostingController(rootView: nil)
-
- /// Updates content of the cell
- /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
- func set(content: Hosted, parent: UIViewController) {
- hostingController.view.backgroundColor = .clear
- hostingController.rootView = content
- if let hostingView = hostingController.view {
- hostingView.invalidateIntrinsicContentSize()
- if hostingController.parent != parent { parent.addChild(hostingController) }
- if !contentView.subviews.contains(hostingController.view) {
- contentView.addSubview(hostingController.view)
- hostingView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- hostingView.leadingAnchor
- .constraint(equalTo: contentView.leadingAnchor),
- hostingView.trailingAnchor
- .constraint(equalTo: contentView.trailingAnchor),
- hostingView.topAnchor
- .constraint(equalTo: contentView.topAnchor),
- hostingView.bottomAnchor
- .constraint(equalTo: contentView.bottomAnchor)
- ])
- }
- if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
- } else {
- fatalError("Hosting View not loaded \(hostingController)")
- }
- }
-
- override func prepareForReuse() {
- super.prepareForReuse()
- hostingController.rootView = nil
- }
- }
-}
-
-/// Manages ``ReverseList`` scrolling
-class ReverseListScrollModel: ObservableObject {
- /// Represents Scroll State of ``ReverseList``
- enum State: Equatable {
- enum Destination: Equatable {
- case nextPage
- case item(Item.ID)
- case bottom
- }
-
- case scrollingTo(Destination)
- case atDestination
- }
-
- @Published var state: State = .atDestination
-
- func scrollToNextPage() {
- state = .scrollingTo(.nextPage)
- }
-
- func scrollToBottom() {
- state = .scrollingTo(.bottom)
- }
-
- func scrollToItem(id: Item.ID) {
- state = .scrollingTo(.item(id))
- }
-}
-
-fileprivate let cellReuseId = "hostingCell"
-
-fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
-
-fileprivate extension CGAffineTransform {
- /// Transform that vertically flips the view, preserving it's location
- static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
-}
-
-extension NotificationCenter {
- static func postReverseListNeedsLayout() {
- NotificationCenter.default.post(
- name: notificationName,
- object: nil
- )
- }
-}
-
-/// Disable animation on iOS 15
-func withConditionalAnimation(
- _ animation: Animation? = .default,
- _ body: () throws -> Result
-) rethrows -> Result {
- if #available(iOS 16.0, *) {
- try withAnimation(animation, body)
- } else {
- try body()
- }
-}
-
-class InvertedTableView: UITableView {
- static let inset = CGFloat(100)
-
- static let insets = UIEdgeInsets(
- top: inset,
- left: .zero,
- bottom: inset,
- right: .zero
- )
-
- override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
- get { .never }
- set { }
- }
-
- override var contentInset: UIEdgeInsets {
- get { Self.insets }
- set { }
- }
-
- override var adjustedContentInset: UIEdgeInsets {
- Self.insets
- }
-}
diff --git a/apps/ios/Shared/Views/Chat/ScrollViewCells.swift b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift
new file mode 100644
index 0000000000..d062627d5b
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift
@@ -0,0 +1,52 @@
+//
+// ScrollViewCells.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 27.01.2025.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+protocol ReusableView {
+ func prepareForReuse()
+}
+
+/// `UIHostingConfiguration` back-port for iOS14 and iOS15
+/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController`
+final class HostingCell: UIView, ReusableView {
+ private let hostingController = UIHostingController(rootView: nil)
+
+ /// Updates content of the cell
+ /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
+ func set(content: Hosted, parent: UIViewController) {
+ hostingController.view.backgroundColor = .clear
+ hostingController.rootView = content
+ if let hostingView = hostingController.view {
+ hostingView.invalidateIntrinsicContentSize()
+ if hostingController.parent != parent { parent.addChild(hostingController) }
+ if !subviews.contains(hostingController.view) {
+ addSubview(hostingController.view)
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ hostingView.leadingAnchor
+ .constraint(equalTo: leadingAnchor),
+ hostingView.trailingAnchor
+ .constraint(equalTo: trailingAnchor),
+ hostingView.topAnchor
+ .constraint(equalTo: topAnchor),
+ hostingView.bottomAnchor
+ .constraint(equalTo: bottomAnchor)
+ ])
+ }
+ if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
+ } else {
+ fatalError("Hosting View not loaded \(hostingController)")
+ }
+ }
+
+ func prepareForReuse() {
+ //super.prepareForReuse()
+ hostingController.rootView = nil
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
index 87bc73a60e..85d6b279c5 100644
--- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
+++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
@@ -30,15 +30,22 @@ struct SelectedItemsBottomToolbar: View {
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
+ var archiveItems: () -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
+ var forwardItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
+ @State var canArchiveReports: Bool = false
+
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
- @State var allButtonsDisabled = false
+ @State var forwardEnabled: Bool = false
+
+ @State var deleteCountProhibited = false
+ @State var forwardCountProhibited = false
var body: some View {
VStack(spacing: 0) {
@@ -46,14 +53,19 @@ struct SelectedItemsBottomToolbar: View {
HStack(alignment: .center) {
Button {
- deleteItems(deleteForEveryoneEnabled)
+ if canArchiveReports {
+ archiveItems()
+ } else {
+ deleteItems(deleteForEveryoneEnabled)
+ }
} label: {
Image(systemName: "trash")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
+ .foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
}
- .disabled(!deleteEnabled || allButtonsDisabled)
+ .disabled(!deleteEnabled || deleteCountProhibited)
Spacer()
Button {
@@ -61,24 +73,24 @@ struct SelectedItemsBottomToolbar: View {
} label: {
Image(systemName: "flag")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
+ .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
}
- .disabled(!moderateEnabled || allButtonsDisabled)
+ .disabled(!moderateEnabled || deleteCountProhibited)
.opacity(canModerate ? 1 : 0)
-
Spacer()
Button {
- //shareItems()
+ forwardItems()
} label: {
- Image(systemName: "square.and.arrow.up")
+ Image(systemName: "arrowshape.turn.up.forward")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
+ .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
}
- .disabled(allButtonsDisabled)
- .opacity(0)
+ .disabled(!forwardEnabled || forwardCountProhibited)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
@@ -101,20 +113,28 @@ struct SelectedItemsBottomToolbar: View {
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set?) {
let count = selectedItems?.count ?? 0
- allButtonsDisabled = count == 0 || count > 20
+ deleteCountProhibited = count == 0 || count > 200
+ forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
+ let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo {
+ info
+ } else {
+ nil
+ }
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
- (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
+ (deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
- var (de, dee, me, onlyOwnGroupItems, sel) = r
+ var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf
- dee = dee && ci.meta.deletable && !ci.localNote
- onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
- me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
+ dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport
+ ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator
+ onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport
+ me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport
+ fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
- return (de, dee, me, onlyOwnGroupItems, sel)
+ return (de, dee, ar, me, onlyOwnGroupItems, fe, sel)
} else {
return r
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
index c84cdb0b97..7abab33177 100644
--- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
@@ -10,7 +10,7 @@ import SwiftUI
struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel
- @Binding var showSettings: Bool
+ let dismissSettingsSheet: DismissAction
var body: some View {
ScrollView { chatHelp() }
@@ -23,7 +23,7 @@ struct ChatHelp: View {
VStack(alignment: .leading, spacing: 0) {
Text("To ask any questions and to receive updates:")
Button("connect to SimpleX Chat developers.") {
- showSettings = false
+ dismissSettingsSheet()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
}
@@ -42,7 +42,8 @@ struct ChatHelp: View {
Text("above, then choose:")
}
- Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
+ Text("**Create 1-time link**: to create and share a new invitation link.")
+ Text("**Scan / Paste link**: to connect via a link you received.")
Text("**Create group**: to create a new group.")
}
.padding(.top, 24)
@@ -61,8 +62,9 @@ struct ChatHelp: View {
}
struct ChatHelp_Previews: PreviewProvider {
+ @Environment(\.dismiss) static var mockDismiss
+
static var previews: some View {
- @State var showSettings = false
- return ChatHelp(showSettings: $showSettings)
+ ChatHelp(dismissSettingsSheet: mockDismiss)
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index d2a93b9bd1..81d78fbadd 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -43,9 +43,11 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var chatTagsModel: ChatTagsModel
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat
+ @Binding var parentSheet: SomeSheet?
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@@ -85,13 +87,14 @@ struct ChatListNavLink: View {
progressByTimeout = false
}
}
+ .actionSheet(item: $actionSheet) { $0.actionSheet }
}
- @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
+ private func contactNavLink(_ contact: Contact) -> some View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
@@ -118,12 +121,14 @@ struct ChatListNavLink: View {
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ tagChatButton(chat)
if !chat.chatItems.isEmpty {
clearChatButton()
}
@@ -141,15 +146,13 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
- .frame(height: dynamicRowHeight)
}
}
.alert(item: $alert) { $0.alert }
- .actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
- .presentationDetents([.fraction(0.4)])
+ .presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
@@ -160,7 +163,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -180,11 +183,12 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
.swipeActions(edge: .trailing) {
+ tagChatButton(chat)
if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo)
}
@@ -199,34 +203,54 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
- if !chat.chatItems.isEmpty {
+ tagChatButton(chat)
+ let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator
+ let showClearButton = !chat.chatItems.isEmpty
+ let showDeleteGroup = groupInfo.canDelete
+ let showLeaveGroup = groupInfo.membership.memberCurrent
+ let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
+
+ if showClearButton && totalNumberOfButtons <= 3 {
clearChatButton()
}
- if (groupInfo.membership.memberCurrent) {
+
+ if showReportsButton && totalNumberOfButtons <= 3 {
+ archiveAllReportsButton()
+ }
+
+ if showLeaveGroup {
leaveGroupChatButton(groupInfo)
}
- if groupInfo.canDelete {
+
+ if showDeleteGroup && totalNumberOfButtons <= 3 {
deleteGroupChatButton(groupInfo)
+ } else if totalNumberOfButtons > 3 {
+ if showDeleteGroup && !groupInfo.membership.memberActive {
+ deleteGroupChatButton(groupInfo)
+ moreOptionsButton(false, chat, groupInfo)
+ } else {
+ moreOptionsButton(true, chat, groupInfo)
+ }
}
}
}
}
- @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
+ private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain(
chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -287,14 +311,22 @@ struct ChatListNavLink: View {
}
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
- Button {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
- } label: {
- if chat.chatInfo.ntfsEnabled {
- SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
- } else {
- SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
+ if let nextMode = chat.chatInfo.nextNtfMode {
+ Button {
+ toggleNotifications(chat, enableNtfs: nextMode)
+ } label: {
+ SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
}
+ } else {
+ EmptyView()
+ }
+ }
+
+ private func archiveAllReportsButton() -> some View {
+ Button {
+ AlertManager.shared.showAlert(archiveAllReportsAlert())
+ } label: {
+ SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI)
}
}
@@ -306,7 +338,72 @@ struct ChatListNavLink: View {
}
.tint(Color.orange)
}
+
+ private func tagChatButton(_ chat: Chat) -> some View {
+ Button {
+ setTagChatSheet(chat)
+ } label: {
+ SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI)
+ }
+ .tint(.mint)
+ }
+
+ private func setTagChatSheet(_ chat: Chat) {
+ let screenHeight = UIScreen.main.bounds.height
+ let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
+ let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
+ let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
+
+ parentSheet = SomeSheet(
+ content: {
+ AnyView(
+ NavigationView {
+ if chatTagsModel.userTags.isEmpty {
+ TagListEditor(chat: chat)
+ } else {
+ TagListView(chat: chat)
+ }
+ }
+ )
+ },
+ id: "lists sheet",
+ fraction: fraction
+ )
+ }
+
+ private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
+ Button {
+ var buttons: [Alert.Button] = []
+ buttons.append(.default(Text("Clear")) {
+ AlertManager.shared.showAlert(clearChatAlert())
+ })
+ if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready {
+ buttons.append(.default(Text("Archive reports")) {
+ AlertManager.shared.showAlert(archiveAllReportsAlert())
+ })
+ }
+
+ if canShowGroupDelete, let gi = groupInfo, gi.canDelete {
+ buttons.append(.destructive(Text("Delete")) {
+ AlertManager.shared.showAlert(deleteGroupAlert(gi))
+ })
+ }
+
+ buttons.append(.cancel())
+
+ actionSheet = SomeActionSheet(
+ actionSheet: ActionSheet(
+ title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"),
+ buttons: buttons
+ ),
+ id: "other options"
+ )
+ } label: {
+ SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI)
+ }
+ }
+
private func clearNoteFolderButton() -> some View {
Button {
AlertManager.shared.showAlert(clearNoteFolderAlert())
@@ -336,6 +433,7 @@ struct ChatListNavLink: View {
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
@@ -354,7 +452,6 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
- .frame(height: dynamicRowHeight)
.contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
@@ -366,6 +463,7 @@ struct ChatListNavLink: View {
private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
ContactConnectionView(chat: chat)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in
@@ -383,7 +481,6 @@ struct ChatListNavLink: View {
}
.tint(theme.colors.primary)
}
- .frame(height: dynamicRowHeight)
.appSheet(isPresented: $showContactConnectionInfo) {
Group {
if case let .contactConnection(contactConnection) = chat.chatInfo {
@@ -404,8 +501,9 @@ struct ChatListNavLink: View {
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
- Alert(
- title: Text("Delete group?"),
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
+ return Alert(
+ title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task { await deleteChat(chat) }
@@ -414,8 +512,25 @@ struct ChatListNavLink: View {
)
}
- private func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
- groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
+ private func archiveAllReportsAlert() -> Alert {
+ Alert(
+ title: Text("Archive all reports?"),
+ message: Text("All reports will be archived for you."),
+ primaryButton: .destructive(Text("Archive")) {
+ Task { await archiveAllReportsForMe(chat.chatInfo.apiId) }
+ },
+ secondaryButton: .cancel()
+ )
+ }
+
+ private func archiveAllReportsForMe(_ apiId: Int64) async {
+ do {
+ if case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member) = try await apiArchiveReceivedReports(groupId: apiId) {
+ await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member)
+ }
+ } catch {
+ logger.error("archiveAllReportsForMe error: \(responseError(error))")
+ }
}
private func clearChatAlert() -> Alert {
@@ -441,9 +556,15 @@ struct ChatListNavLink: View {
}
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
- Alert(
- title: Text("Leave group?"),
- message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
+ let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "You will stop receiving messages from this group. Chat history will be preserved."
+ : "You will stop receiving messages from this chat. Chat history will be preserved."
+ )
+ return Alert(
+ title: Text(titleLabel),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task { await leaveGroup(groupInfo.groupId) }
},
@@ -458,14 +579,14 @@ struct ChatListNavLink: View {
)
}
- private func invalidJSONPreview(_ json: String) -> some View {
+ private func invalidJSONPreview(_ json: Data?) -> some View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) {
- invalidJSONView(json)
+ invalidJSONView(dataToString(json))
.environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
}
}
@@ -474,8 +595,27 @@ struct ChatListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok {
- ItemsModel.shared.loadOpenChat(contact.id)
- AlertManager.shared.showAlert(connReqSentAlert(.contact))
+ ItemsModel.shared.loadOpenChat(contact.id) {
+ AlertManager.shared.showAlert(connReqSentAlert(.contact))
+ }
+ }
+ }
+ }
+}
+
+extension View {
+ @inline(__always)
+ @ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View {
+ if #available(iOS 16, *) {
+ self.frame(height: height)
+ } else {
+ VStack(spacing: 0) {
+ Divider()
+ .padding(.leading, 16)
+ self
+ .frame(height: height)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
}
}
}
@@ -567,7 +707,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
- if let r = error as? ChatResponse,
+ if let r = error as? ChatError,
let alert = getNetworkErrorAlert(r) {
return alert
} else {
@@ -582,15 +722,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
- ))
+ ), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
- ))
+ ), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
- ))
+ ), parentSheet: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 82))
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 8ad03236f1..f34f930c6f 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -9,24 +9,154 @@
import SwiftUI
import SimpleXChat
+enum UserPickerSheet: Identifiable {
+ case address
+ case chatPreferences
+ case chatProfiles
+ case currentProfile
+ case useFromDesktop
+ case settings
+
+ var id: Self { self }
+
+ var navigationTitle: LocalizedStringKey {
+ switch self {
+ case .address: "SimpleX address"
+ case .chatPreferences: "Your preferences"
+ case .chatProfiles: "Your chat profiles"
+ case .currentProfile: "Your current profile"
+ case .useFromDesktop: "Connect to desktop"
+ case .settings: "Your settings"
+ }
+ }
+}
+
+enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
+ case groupReports = 0
+ case favorites = 1
+ case contacts = 2
+ case groups = 3
+ case business = 4
+ case notes = 5
+
+ var id: Int { rawValue }
+
+ var сollapse: Bool {
+ self != .groupReports
+ }
+}
+
+enum ActiveFilter: Identifiable, Equatable {
+ case presetTag(PresetTag)
+ case userTag(ChatTag)
+ case unread
+
+ var id: String {
+ switch self {
+ case let .presetTag(tag): "preset \(tag.id)"
+ case let .userTag(tag): "user \(tag.chatTagId)"
+ case .unread: "unread"
+ }
+ }
+}
+
+class SaveableSettings: ObservableObject {
+ @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
+}
+
+struct ServerSettings {
+ public var currUserServers: [UserOperatorServers]
+ public var userServers: [UserOperatorServers]
+ public var serverErrors: [UserServersError]
+}
+
+struct UserPickerSheetView: View {
+ let sheet: UserPickerSheet
+ @EnvironmentObject var chatModel: ChatModel
+ @StateObject private var ss = SaveableSettings()
+
+ @State private var loaded = false
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ if loaded, let currentUser = chatModel.currentUser {
+ switch sheet {
+ case .address:
+ UserAddressView(shareViaProfile: currentUser.addressShared)
+ case .chatPreferences:
+ PreferencesView(
+ profile: currentUser.profile,
+ preferences: currentUser.fullPreferences,
+ currentPreferences: currentUser.fullPreferences
+ )
+ case .chatProfiles:
+ UserProfilesView()
+ case .currentProfile:
+ UserProfile()
+ case .useFromDesktop:
+ ConnectDesktopView()
+ case .settings:
+ SettingsView()
+ }
+ }
+ Color.clear // Required for list background to be rendered during loading
+ }
+ .navigationTitle(sheet.navigationTitle)
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .overlay {
+ if let la = chatModel.laRequest {
+ LocalAuthView(authRequest: la)
+ }
+ }
+ .task {
+ withAnimation(
+ .easeOut(duration: 0.1),
+ { loaded = true }
+ )
+ }
+ .onDisappear {
+ if serversCanBeSaved(
+ ss.servers.currUserServers,
+ ss.servers.userServers,
+ ss.servers.serverErrors
+ ) {
+ showAlert(
+ title: NSLocalizedString("Save servers?", comment: "alert title"),
+ buttonTitle: NSLocalizedString("Save", comment: "alert button"),
+ buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
+ cancelButton: true
+ )
+ }
+ }
+ .environmentObject(ss)
+ }
+}
+
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
+ @Binding var activeUserPickerSheet: UserPickerSheet?
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
- @State private var userPickerVisible = false
- @State private var showConnectDesktop = false
@State private var scrollToSearchBar = false
+ @State private var userPickerShown: Bool = false
+ @State private var sheet: SomeSheet? = nil
+ @StateObject private var chatTagsModel = ChatTagsModel.shared
+
+ // iOS 15 is required it to show/hide toolbar while chat is hidden/visible
+ @State private var viewOnScreen = true
- @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
+ @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
-
+
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
@@ -34,7 +164,7 @@ struct ChatListView: View {
viewBody
}
}
-
+
private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
@@ -46,24 +176,27 @@ struct ChatListView: View {
),
destination: chatView
) { chatListView }
- if userPickerVisible {
- Rectangle().fill(.white.opacity(0.001)).onTapGesture {
- withAnimation {
- userPickerVisible.toggle()
- }
+ }
+ .modifier(
+ Sheet(isPresented: $userPickerShown) {
+ UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
+ }
+ )
+ .appSheet(
+ item: $activeUserPickerSheet,
+ onDismiss: { chatModel.laRequest = nil },
+ content: { UserPickerSheetView(sheet: $0) }
+ )
+ .onChange(of: activeUserPickerSheet) {
+ if $0 != nil {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ userPickerShown = false
}
}
- UserPicker(
- showSettings: $showSettings,
- showConnectDesktop: $showConnectDesktop,
- userPickerVisible: $userPickerVisible
- )
- }
- .sheet(isPresented: $showConnectDesktop) {
- ConnectDesktopView()
}
+ .environmentObject(chatTagsModel)
}
-
+
private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial)
return withToolbar(tm) {
@@ -73,7 +206,17 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .onDisappear() { withAnimation { userPickerVisible = false } }
+ .onAppear {
+ if #unavailable(iOS 16.0), !viewOnScreen {
+ viewOnScreen = true
+ }
+ }
+ .onDisappear {
+ activeUserPickerSheet = nil
+ if #unavailable(iOS 16.0) {
+ viewOnScreen = false
+ }
+ }
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -98,15 +241,22 @@ struct ChatListView: View {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
}
+ .sheet(item: $sheet) { sheet in
+ if #available(iOS 16.0, *) {
+ sheet.content.presentationDetents([.fraction(sheet.fraction)])
+ } else {
+ sheet.content
+ }
+ }
}
-
+
static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0
} else { false }
}()
-
+
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) {
if oneHandUI {
@@ -121,19 +271,19 @@ struct ChatListView: View {
}
} else {
if oneHandUI {
- content().toolbar { bottomToolbarGroup }
+ content().toolbar { bottomToolbarGroup() }
} else {
content().toolbar { topToolbar }
}
}
}
-
+
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
-
+
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
@@ -148,10 +298,10 @@ struct ChatListView: View {
.onTapGesture { scrollToSearchBar = true }
}
}
-
- @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
+
+ @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
- ToolbarItemGroup(placement: .bottomBar) {
+ ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
@@ -159,12 +309,12 @@ struct ChatListView: View {
trailingToolbarItem.padding(.bottom, padding)
}
}
-
+
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
- .padding(.trailing, 4)
+ .padding([.top, .trailing], 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
@@ -173,16 +323,10 @@ struct ChatListView: View {
}
}
.onTapGesture {
- if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
- withAnimation {
- userPickerVisible.toggle()
- }
- } else {
- showSettings = true
- }
+ userPickerShown = true
}
}
-
+
@ViewBuilder var trailingToolbarItem: some View {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton()
@@ -190,10 +334,10 @@ struct ChatListView: View {
case .none: EmptyView()
}
}
-
- @ViewBuilder private var chatList: some View {
+
+ private var chatList: some View {
let cs = filteredChats()
- ZStack {
+ return ZStack {
ScrollViewReader { scrollProxy in
List {
if !chatModel.chats.isEmpty {
@@ -202,7 +346,8 @@ struct ChatListView: View {
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
- searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
+ searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
+ parentSheet: $sheet
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
@@ -211,15 +356,9 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
- if !oneHandUICardShown {
- OneHandUICard()
- .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- }
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
- ChatListNavLink(chat: chat)
+ ChatListNavLink(chat: chat, parentSheet: $sheet)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
@@ -228,13 +367,7 @@ struct ChatListView: View {
.offset(x: -8)
} else {
ForEach(cs, id: \.viewId) { chat in
- VStack(spacing: .zero) {
- Divider()
- .padding(.leading, 16)
- ChatListNavLink(chat: chat)
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
- }
+ ChatListNavLink(chat: chat, parentSheet: $sheet)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
@@ -242,6 +375,20 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
+ if !oneHandUICardShown {
+ OneHandUICard()
+ .padding(.vertical, 6)
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+ }
+ if !addressCreationCardShown {
+ AddressCreationCard()
+ .padding(.vertical, 6)
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+ }
}
.listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in
@@ -262,80 +409,97 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
- Text("No filtered chats")
+ noChatsView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
+
+ @ViewBuilder private func noChatsView() -> some View {
+ if searchString().isEmpty {
+ switch chatTagsModel.activeFilter {
+ case .presetTag: Text("No filtered chats") // this should not happen
+ case let .userTag(tag): Text("No chats in list \(tag.chatTagText)")
+ case .unread:
+ Button {
+ chatTagsModel.activeFilter = nil
+ } label: {
+ HStack {
+ Image(systemName: "line.3.horizontal.decrease")
+ Text("No unread chats")
+ }
+ }
+ case .none: Text("No chats")
+ }
+ } else {
+ Text("No chats found")
+ }
+ }
- private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
+
+ private func unreadBadge(size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
}
-
+
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat)
}
}
-
+
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
-
+
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
- return s == "" && !showUnreadAndFavorites
+ return s == ""
? chatModel.chats.filter { chat in
- !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
+ !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat)
}
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
- switch cInfo {
+ return switch cInfo {
case let .direct(contact):
- return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
- s == ""
- ? filtered(chat)
- : (viewNameContains(cInfo, s) ||
- contact.profile.displayName.localizedLowercase.contains(s) ||
- contact.fullName.localizedLowercase.contains(s))
+ !contact.chatDeleted && !chat.chatInfo.contactCard && (
+ ( viewNameContains(cInfo, s) ||
+ contact.profile.displayName.localizedLowercase.contains(s) ||
+ contact.fullName.localizedLowercase.contains(s)
+ )
)
- case let .group(gInfo):
- return s == ""
- ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
- : viewNameContains(cInfo, s)
- case .local:
- return s == "" || viewNameContains(cInfo, s)
- case .contactRequest:
- return s == "" || viewNameContains(cInfo, s)
- case let .contactConnection(conn):
- return s != "" && conn.localAlias.localizedLowercase.contains(s)
- case .invalidJSON:
- return false
+ case .group: viewNameContains(cInfo, s)
+ case .local: viewNameContains(cInfo, s)
+ case .contactRequest: viewNameContains(cInfo, s)
+ case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s)
+ case .invalidJSON: false
}
}
}
-
- func searchString() -> String {
- searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
- }
-
+
func filtered(_ chat: Chat) -> Bool {
- (chat.chatInfo.chatSettings?.favorite ?? false) ||
- chat.chatStats.unreadChat ||
- (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
+ switch chatTagsModel.activeFilter {
+ case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
+ case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
+ case .unread: chat.unreadTag
+ case .none: true
+ }
}
-
+
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
+
+ func searchString() -> String {
+ searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
+ }
}
struct SubsStatusIndicator: View {
@@ -374,7 +538,7 @@ struct SubsStatusIndicator: View {
private func startTask() {
task = Task {
while !Task.isCancelled {
- if AppChatState.shared.value == .active {
+ if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true {
do {
let (subs, hasSess) = try await getAgentSubsTotal()
await MainActor.run {
@@ -399,18 +563,20 @@ struct SubsStatusIndicator: View {
struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var chatTagsModel: ChatTagsModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
+ @Binding var parentSheet: SomeSheet?
@State private var ignoreSearchTextChange = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
- @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
+ ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -468,6 +634,9 @@ struct ChatListSearchBar: View {
}
}
}
+ .onChange(of: chatTagsModel.activeFilter) { _ in
+ searchText = ""
+ }
.alert(item: $alert) { a in
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
}
@@ -477,16 +646,21 @@ struct ChatListSearchBar: View {
}
private func toggleFilterButton() -> some View {
- ZStack {
+ let showUnread = chatTagsModel.activeFilter == .unread
+ return ZStack {
Color.clear
.frame(width: 22, height: 22)
- Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
+ Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
- .foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
- .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
+ .foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary)
+ .frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
.onTapGesture {
- showUnreadAndFavorites = !showUnreadAndFavorites
+ if chatTagsModel.activeFilter == .unread {
+ chatTagsModel.activeFilter = nil
+ } else {
+ chatTagsModel.activeFilter = .unread
+ }
}
}
}
@@ -504,6 +678,198 @@ struct ChatListSearchBar: View {
}
}
+struct TagsView: View {
+ @EnvironmentObject var chatTagsModel: ChatTagsModel
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Binding var parentSheet: SomeSheet?
+ @Binding var searchText: String
+
+ var body: some View {
+ HStack {
+ tagsView()
+ }
+ }
+
+ @ViewBuilder private func tagsView() -> some View {
+ if chatTagsModel.presetTags.count > 1 {
+ if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 {
+ expandedPresetTagsFiltersView()
+ } else {
+ collapsedTagsFilterView()
+ ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in
+ if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 {
+ expandedTagFilterView(tag)
+ }
+ }
+ }
+ }
+ let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
+ tag
+ } else {
+ nil
+ }
+ ForEach(chatTagsModel.userTags, id: \.id) { tag in
+ let current = tag == selectedTag
+ let color: Color = current ? .accentColor : .secondary
+ ZStack {
+ HStack(spacing: 4) {
+ if let emoji = tag.chatTagEmoji {
+ Text(emoji)
+ } else {
+ Image(systemName: current ? "tag.fill" : "tag")
+ .foregroundColor(color)
+ }
+ ZStack {
+ let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? " ●" : "").font(.footnote)
+ (Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear)
+ Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary)
+ }
+ }
+ .onTapGesture {
+ setActiveFilter(filter: .userTag(tag))
+ }
+ .onLongPressGesture {
+ let screenHeight = UIScreen.main.bounds.height
+ let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
+ let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
+ let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
+
+ parentSheet = SomeSheet(
+ content: {
+ AnyView(
+ NavigationView {
+ TagListView(chat: nil)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ )
+ },
+ id: "tag list",
+ fraction: fraction
+ )
+ }
+ }
+ }
+
+ Button {
+ parentSheet = SomeSheet(
+ content: {
+ AnyView(
+ NavigationView {
+ TagListEditor()
+ }
+ )
+ },
+ id: "tag create"
+ )
+ } label: {
+ if chatTagsModel.userTags.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "plus")
+ Text("Add list")
+ }
+ } else {
+ Image(systemName: "plus")
+ }
+ }
+ .foregroundColor(.secondary)
+ }
+
+ @ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View {
+ let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
+ tag
+ } else {
+ nil
+ }
+ let active = tag == selectedPresetTag
+ let (icon, text) = presetTagLabel(tag: tag, active: active)
+ let color: Color = active ? .accentColor : .secondary
+
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .foregroundColor(color)
+ ZStack {
+ Text(text).fontWeight(.semibold).foregroundColor(.clear)
+ Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
+ }
+ }
+ .onTapGesture {
+ setActiveFilter(filter: .presetTag(tag))
+ }
+ }
+
+ private func expandedPresetTagsFiltersView() -> some View {
+ ForEach(PresetTag.allCases, id: \.id) { tag in
+ if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
+ expandedTagFilterView(tag)
+ }
+ }
+ }
+
+ @ViewBuilder private func collapsedTagsFilterView() -> some View {
+ let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
+ tag
+ } else {
+ nil
+ }
+ Menu {
+ if chatTagsModel.activeFilter != nil || !searchText.isEmpty {
+ Button {
+ chatTagsModel.activeFilter = nil
+ searchText = ""
+ } label: {
+ HStack {
+ Image(systemName: "list.bullet")
+ Text("All")
+ }
+ }
+ }
+ ForEach(PresetTag.allCases, id: \.id) { tag in
+ if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse {
+ Button {
+ setActiveFilter(filter: .presetTag(tag))
+ } label: {
+ let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
+ HStack {
+ Image(systemName: systemName)
+ Text(text)
+ }
+ }
+ }
+ }
+ } label: {
+ if let tag = selectedPresetTag, tag.сollapse {
+ let (systemName, _) = presetTagLabel(tag: tag, active: true)
+ Image(systemName: systemName)
+ .foregroundColor(.accentColor)
+ } else {
+ Image(systemName: "list.bullet")
+ .foregroundColor(.secondary)
+ }
+ }
+ .frame(minWidth: 28)
+ }
+
+ private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
+ switch tag {
+ case .groupReports: (active ? "flag.fill" : "flag", "Reports")
+ case .favorites: (active ? "star.fill" : "star", "Favorites")
+ case .contacts: (active ? "person.fill" : "person", "Contacts")
+ case .groups: (active ? "person.2.fill" : "person.2", "Groups")
+ case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
+ case .notes: (active ? "folder.fill" : "folder", "Notes")
+ }
+ }
+
+ private func setActiveFilter(filter: ActiveFilter) {
+ if filter != chatTagsModel.activeFilter {
+ chatTagsModel.activeFilter = filter
+ } else {
+ chatTagsModel.activeFilter = nil
+ }
+ }
+}
+
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
@@ -515,7 +881,38 @@ func chatStoppedIcon() -> some View {
}
}
+func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
+ switch tag {
+ case .groupReports:
+ chatStats.reportsCount > 0
+ case .favorites:
+ chatInfo.chatSettings?.favorite == true
+ case .contacts:
+ switch chatInfo {
+ case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
+ case .contactRequest: true
+ case .contactConnection: true
+ case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
+ default: false
+ }
+ case .groups:
+ switch chatInfo {
+ case let .group(groupInfo): groupInfo.businessChat == nil
+ default: false
+ }
+ case .business:
+ chatInfo.groupInfo?.businessChat?.chatType == .business
+ case .notes:
+ switch chatInfo {
+ case .local: true
+ default: false
+ }
+ }
+}
+
struct ChatListView_Previews: PreviewProvider {
+ @State static var userPickerSheet: UserPickerSheet? = .none
+
static var previews: some View {
let chatModel = ChatModel()
chatModel.updateChats([
@@ -534,9 +931,9 @@ struct ChatListView_Previews: PreviewProvider {
])
return Group {
- ChatListView(showSettings: Binding.constant(false))
+ ChatListView(activeUserPickerSheet: $userPickerSheet)
.environmentObject(chatModel)
- ChatListView(showSettings: Binding.constant(false))
+ ChatListView(activeUserPickerSheet: $userPickerSheet)
.environmentObject(ChatModel())
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 9e6d3005b6..b8c8233e6e 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -27,67 +27,80 @@ struct ChatPreviewView: View {
var body: some View {
let cItem = chat.chatItems.last
- return HStack(spacing: 8) {
- ZStack(alignment: .bottomTrailing) {
- ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
- chatPreviewImageOverlayIcon()
- .padding([.bottom, .trailing], 1)
- }
- .padding(.leading, 4)
-
- VStack(spacing: 0) {
- HStack(alignment: .top) {
- chatPreviewTitle()
- Spacer()
- (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
- .font(.subheadline)
- .frame(minWidth: 60, alignment: .trailing)
- .foregroundColor(theme.colors.secondary)
- .padding(.top, 4)
+ return ZStack {
+ HStack(spacing: 8) {
+ ZStack(alignment: .bottomTrailing) {
+ ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
+ chatPreviewImageOverlayIcon()
+ .padding([.bottom, .trailing], 1)
}
- .padding(.bottom, 4)
- .padding(.horizontal, 8)
-
- ZStack(alignment: .topTrailing) {
- let chat = activeContentPreview?.chat ?? chat
- let ci = activeContentPreview?.ci ?? chat.chatItems.last
- let mc = ci?.content.msgContent
- HStack(alignment: .top) {
- let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
- let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
- if let ci, showContentPreview {
- chatItemContentPreview(chat, ci)
- }
- let mcIsVoice = switch mc { case .voice: true; default: false }
- if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
- let hasFilePreview = if case .file = mc { true } else { false }
- chatMessagePreview(cItem, hasFilePreview)
- } else {
- Spacer()
- chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
- }
- }
- .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- .onChange(of: activeContentPreview) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- .onChange(of: showFullscreenGallery) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- chatStatusImage()
- .padding(.top, dynamicChatInfoSize * 1.44)
- .frame(maxWidth: .infinity, alignment: .trailing)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.trailing, 8)
+ .padding(.leading, 4)
- Spacer()
+ let chatTs = if let cItem {
+ cItem.meta.itemTs
+ } else {
+ chat.chatInfo.chatTs
+ }
+ VStack(spacing: 0) {
+ HStack(alignment: .top) {
+ chatPreviewTitle()
+ Spacer()
+ (formatTimestampText(chatTs))
+ .font(.subheadline)
+ .frame(minWidth: 60, alignment: .trailing)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top, 4)
+ }
+ .padding(.bottom, 4)
+ .padding(.horizontal, 8)
+
+ ZStack(alignment: .topTrailing) {
+ let chat = activeContentPreview?.chat ?? chat
+ let ci = activeContentPreview?.ci ?? chat.chatItems.last
+ let mc = ci?.content.msgContent
+ HStack(alignment: .top) {
+ let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
+ let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
+ if let ci, showContentPreview {
+ chatItemContentPreview(chat, ci)
+ }
+ let mcIsVoice = switch mc { case .voice: true; default: false }
+ if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
+ let hasFilePreview = if case .file = mc { true } else { false }
+ chatMessagePreview(cItem, hasFilePreview)
+ } else {
+ Spacer()
+ chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
+ }
+ }
+ .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
+ checkActiveContentPreview(chat, ci, mc)
+ }
+ .onChange(of: activeContentPreview) { _ in
+ checkActiveContentPreview(chat, ci, mc)
+ }
+ .onChange(of: showFullscreenGallery) { _ in
+ checkActiveContentPreview(chat, ci, mc)
+ }
+ chatStatusImage()
+ .padding(.top, dynamicChatInfoSize * 1.44)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.trailing, 8)
+
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .opacity(deleting ? 0.4 : 1)
+ .padding(.bottom, -8)
+
+ if deleting {
+ ProgressView()
+ .scaleEffect(2)
}
- .frame(maxHeight: .infinity)
}
- .padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
// Stop voice when deleting the chat
@@ -130,6 +143,7 @@ struct ChatPreviewView: View {
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) {
+ case .memRejected: inactiveIcon()
case .memLeft: inactiveIcon()
case .memRemoved: inactiveIcon()
case .memGroupDeleted: inactiveIcon()
@@ -140,7 +154,7 @@ struct ChatPreviewView: View {
}
}
- @ViewBuilder private func inactiveIcon() -> some View {
+ private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
@@ -155,7 +169,7 @@ struct ChatPreviewView: View {
let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) {
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
- case .memAccepted: v.foregroundColor(theme.colors.secondary)
+ case .memAccepted, .memRejected: v.foregroundColor(theme.colors.secondary)
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
}
default: previewTitle(t)
@@ -167,20 +181,23 @@ struct ChatPreviewView: View {
}
private var verifiedIcon: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
}
- private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
+ private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View {
ZStack(alignment: .topTrailing) {
+ let s = chat.chatStats
+ let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 }
let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.multilineTextAlignment(.leading)
+ .if(hasSecrets, transform: hiddenSecretsView)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8)
- .padding(.trailing, hasFilePreview ? 38 : 36)
+ .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft {
@@ -195,19 +212,34 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
- unreadCountText(s.unreadCount)
- .font(userFont <= .xxxLarge ? .caption : .caption2)
- .foregroundColor(.white)
- .padding(.horizontal, dynamicSize(userFont).unreadPadding)
- .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
- .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
- .cornerRadius(dynamicSize(userFont).unreadCorner)
- } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
- Image(systemName: "speaker.slash.fill")
+ let mentionColor = mentionColor(chat)
+ HStack(alignment: .center, spacing: 2) {
+ if s.unreadMentions > 0 && s.unreadCount > 1 {
+ Text("\(MENTION_START)")
+ .font(userFont <= .xxxLarge ? .body : .callout)
+ .foregroundColor(mentionColor)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ .padding(.bottom, 1)
+ }
+ let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1
+ (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount))
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
+ .frame(height: dynamicChatInfoSize)
+ } else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all {
+ let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize
+ let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary
+ Image(systemName: ntfMode.iconFilled)
.resizable()
.scaledToFill()
- .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
- .foregroundColor(theme.colors.secondary)
+ .frame(width: iconSize, height: iconSize)
+ .foregroundColor(iconColor)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
@@ -219,20 +251,30 @@ struct ChatPreviewView: View {
Color.clear.frame(width: 0)
}
}
+
+ private func mentionColor(_ chat: Chat) -> Color {
+ switch chat.chatInfo.chatSettings?.enableNtfs {
+ case .all: theme.colors.primary
+ case .mentions: theme.colors.primary
+ default: theme.colors.secondary
+ }
+ }
- private func messageDraft(_ draft: ComposeState) -> Text {
+ private func messageDraft(_ draft: ComposeState) -> (Text, Bool) {
let msg = draft.message
- return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
- + attachment()
- + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background))
+ return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ + attachment()
+ + Text(AttributedString(r.string)),
+ r.hasSecrets)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
- Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
+ Text(Image(systemName: s)).foregroundColor(color) + textSpace
}
func attachment() -> Text {
switch draft.preview {
- case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
+ case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + textSpace
case .mediaPreviews: return image("photo")
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
default: return Text("")
@@ -240,19 +282,24 @@ struct ChatPreviewView: View {
}
}
- func chatItemPreview(_ cItem: ChatItem) -> Text {
+ func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
- return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ let r = messageText(itemText, itemFormattedText, sender: 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)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String {
- switch cItem.meta.itemDeleted {
- case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
- case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
- case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
- case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
+ if cItem.meta.itemDeleted != nil, cItem.isReport {
+ "archived report"
+ } else {
+ switch cItem.meta.itemDeleted {
+ case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
+ case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
+ case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
+ case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
+ }
}
}
@@ -265,13 +312,22 @@ struct ChatPreviewView: View {
default: return nil
}
}
+
+ func prefix() -> NSAttributedString? {
+ switch cItem.content.msgContent {
+ case let .report(_, reason): reason.attrString
+ default: nil
+ }
+ }
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
- chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
+ let (t, hasSecrets) = messageDraft(draft)
+ chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else if let cItem = cItem {
- chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
+ let (t, hasSecrets) = chatItemPreview(cItem)
+ chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else {
switch (chat.chatInfo) {
case let .direct(contact):
@@ -287,6 +343,7 @@ struct ChatPreviewView: View {
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) {
+ case .memRejected: chatPreviewInfoText("rejected")
case .memInvited: groupInvitationPreviewText(groupInfo)
case .memAccepted: chatPreviewInfoText("connecting…")
default: EmptyView()
@@ -302,7 +359,7 @@ struct ChatPreviewView: View {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
- Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
+ Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
@@ -318,18 +375,16 @@ struct ChatPreviewView: View {
.cornerRadius(8)
}
.onTapGesture {
- UIApplication.shared.open(preview.uri)
+ openBrowserAlert(uri: preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
- CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
+ CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
- CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
+ CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -350,7 +405,7 @@ struct ChatPreviewView: View {
: chatPreviewInfoText("you are invited to group")
}
- @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
+ private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
Text(text)
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
@@ -362,11 +417,11 @@ struct ChatPreviewView: View {
case .sndErrorAuth, .sndError:
return Text(Image(systemName: "multiply"))
.font(.caption)
- .foregroundColor(.red) + Text(" ")
+ .foregroundColor(.red) + textSpace
case .sndWarning:
return Text(Image(systemName: "exclamationmark.triangle.fill"))
.font(.caption)
- .foregroundColor(.orange) + Text(" ")
+ .foregroundColor(.orange) + textSpace
default: return Text("")
}
}
@@ -383,6 +438,8 @@ struct ChatPreviewView: View {
case .group:
if progressByTimeout {
ProgressView()
+ } else if chat.chatStats.reportsCount > 0 {
+ groupReportsIcon(size: size * 0.8)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -428,6 +485,14 @@ struct ChatPreviewView: View {
}
}
+func groupReportsIcon(size: CGFloat) -> some View {
+ Image(systemName: "flag")
+ .resizable()
+ .scaledToFit()
+ .frame(width: size, height: size)
+ .foregroundColor(.red)
+}
+
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
index 0f64b632dc..b9f5b984e1 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
@@ -14,6 +14,7 @@ struct ContactConnectionInfo: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection
+ @State private var showShortLink: Bool = true
@State private var alert: CCInfoAlert?
@State private var localAlias = ""
@State private var showIncognitoSheet = false
@@ -61,14 +62,19 @@ struct ContactConnectionInfo: View {
}
if contactConnection.initiated,
- let connReqInv = contactConnection.connReqInv {
- SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
+ let connLinkInv = contactConnection.connLinkInv {
+ SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink)
+ .id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))")
incognitoEnabled()
- shareLinkButton(connReqInv, theme.colors.secondary)
- oneTimeLinkLearnMoreButton(theme.colors.secondary)
+ shareLinkButton(connLinkInv, short: showShortLink)
+ oneTimeLinkLearnMoreButton()
} else {
incognitoEnabled()
- oneTimeLinkLearnMoreButton(theme.colors.secondary)
+ oneTimeLinkLearnMoreButton()
+ }
+ } header: {
+ if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil {
+ ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink)
}
} footer: {
sharedProfileInfo(contactConnection.incognito)
@@ -167,26 +173,22 @@ struct ContactConnectionInfo: View {
}
}
-private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
+private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View {
Button {
- showShareSheet(items: [simplexChatLink(connReqInvitation)])
+ showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)])
} label: {
- settingsRow("square.and.arrow.up", color: secondaryColor) {
- Text("Share 1-time link")
- }
+ Label("Share 1-time link", systemImage: "square.and.arrow.up")
}
}
-private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
+private func oneTimeLinkLearnMoreButton() -> some View {
NavigationLink {
AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
- settingsRow("info.circle", color: secondaryColor) {
- Text("Learn more")
- }
+ Label("Learn more", systemImage: "info.circle")
}
}
diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
index 636d165114..059f24cc82 100644
--- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
+++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
@@ -32,7 +32,6 @@ struct OneHandUICard: View {
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
- .padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
index 477a78e36d..8b0a8af888 100644
--- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
+++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
@@ -245,7 +245,7 @@ struct ServersSummaryView: View {
}
}
- @ViewBuilder private func smpServersListView(
+ private func smpServersListView(
_ servers: [SMPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
@@ -256,7 +256,7 @@ struct ServersSummaryView: View {
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
: $0.hasSubs && !$1.hasSubs
}
- Section {
+ return Section {
ForEach(sortedServers) { server in
smpServerView(server, statsStartedAt)
}
@@ -318,14 +318,14 @@ struct ServersSummaryView: View {
return onionHosts == .require ? .indigo : .accentColor
}
- @ViewBuilder private func xftpServersListView(
+ private func xftpServersListView(
_ servers: [XFTPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil
) -> some View {
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
- Section {
+ return Section {
ForEach(sortedServers) { server in
xftpServerView(server, statsStartedAt)
}
@@ -407,12 +407,18 @@ struct ServersSummaryView: View {
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let onionHosts = networkUseOnionHostsGroupDefault.get()
- let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
+ let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
+ online: m.networkInfo.online,
+ usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
+ subs: subs,
+ hasSess: hasSess,
+ primaryColor: theme.colors.primary
+ )
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
@@ -425,26 +431,32 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let onionHosts = networkUseOnionHostsGroupDefault.get()
- let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
+ let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
+ online: m.networkInfo.online,
+ usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
+ subs: subs,
+ hasSess: hasSess,
+ primaryColor: theme.colors.primary
+ )
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
-func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
+func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
: (n * 4).rounded() / 4
}
- let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
+ let activeColor: Color = usesProxy ? .indigo : primaryColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
@@ -479,15 +491,6 @@ struct SMPServerSummaryView: View {
Section("Server address") {
Text(summary.smpServer)
.textSelection(.enabled)
- if summary.known == true {
- NavigationLink {
- ProtocolServersView(serverProtocol: .smp)
- .navigationTitle("Your SMP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Open server settings")
- }
- }
}
if let stats = summary.stats {
@@ -584,7 +587,7 @@ struct SMPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
}
}
}
@@ -667,15 +670,6 @@ struct XFTPServerSummaryView: View {
Section("Server address") {
Text(summary.xftpServer)
.textSelection(.enabled)
- if summary.known == true {
- NavigationLink {
- ProtocolServersView(serverProtocol: .xftp)
- .navigationTitle("Your XFTP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Open server settings")
- }
- }
}
if let stats = summary.stats {
@@ -709,7 +703,7 @@ struct XFTPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
}
}
}
diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift
new file mode 100644
index 0000000000..2063fe15de
--- /dev/null
+++ b/apps/ios/Shared/Views/ChatList/TagListView.swift
@@ -0,0 +1,408 @@
+//
+// TagListView.swift
+// SimpleX (iOS)
+//
+// Created by Diogo Cunha on 31/12/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+import ElegantEmojiPicker
+
+struct TagEditorNavParams {
+ let chat: Chat?
+ let chatListTag: ChatTagData?
+ let tagId: Int64?
+}
+
+struct TagListView: View {
+ var chat: Chat? = nil
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var chatTagsModel: ChatTagsModel
+ @EnvironmentObject var m: ChatModel
+ @State private var editMode = EditMode.inactive
+ @State private var tagEditorNavParams: TagEditorNavParams? = nil
+
+ var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
+
+ var body: some View {
+ List {
+ Section {
+ ForEach(chatTagsModel.userTags, id: \.id) { tag in
+ let text = tag.chatTagText
+ let emoji = tag.chatTagEmoji
+ let tagId = tag.chatTagId
+ let selected = chatTagsIds.contains(tagId)
+
+ HStack {
+ if let emoji {
+ Text(emoji)
+ } else {
+ Image(systemName: "tag")
+ }
+ Text(text)
+ .padding(.leading, 12)
+ Spacer()
+ if chat != nil {
+ radioButton(selected: selected)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if let c = chat {
+ setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
+ } else {
+ tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button {
+ showAlert(
+ NSLocalizedString("Delete list?", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Delete", comment: "alert action"),
+ style: .destructive,
+ handler: { _ in
+ deleteTag(tagId)
+ }
+ )
+ ]}
+ )
+ } label: {
+ Label("Delete", systemImage: "trash.fill")
+ }
+ .tint(.red)
+ }
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ Button {
+ tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
+ } label: {
+ Label("Edit", systemImage: "pencil")
+ }
+ .tint(theme.colors.primary)
+ }
+ .background(
+ // isActive required to navigate to edit view from any possible tag edited in swipe action
+ NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
+ if let params = tagEditorNavParams {
+ TagListEditor(
+ chat: params.chat,
+ tagId: params.tagId,
+ emoji: params.chatListTag?.emoji,
+ name: params.chatListTag?.text ?? ""
+ )
+ }
+ } label: {
+ EmptyView()
+ }
+ .opacity(0)
+ )
+ }
+ .onMove(perform: moveItem)
+
+ NavigationLink {
+ TagListEditor(chat: chat)
+ } label: {
+ Label("Create list", systemImage: "plus")
+ }
+ } header: {
+ if chat == nil {
+ editTagsButton()
+ .textCase(nil)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .environment(\.editMode, $editMode)
+ }
+
+ private func editTagsButton() -> some View {
+ if editMode.isEditing {
+ Button("Done") {
+ editMode = .inactive
+ dismiss()
+ }
+ } else {
+ Button("Edit") {
+ editMode = .active
+ }
+ }
+ }
+
+ private func radioButton(selected: Bool) -> some View {
+ Image(systemName: selected ? "checkmark.circle.fill" : "circle")
+ .imageScale(.large)
+ .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
+ }
+
+ private func moveItem(from source: IndexSet, to destination: Int) {
+ Task {
+ do {
+ var tags = chatTagsModel.userTags
+ tags.move(fromOffsets: source, toOffset: destination)
+ try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
+
+ await MainActor.run {
+ chatTagsModel.userTags = tags
+ }
+ } catch let error {
+ showAlert(
+ NSLocalizedString("Error reordering lists", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+
+ private func deleteTag(_ tagId: Int64) {
+ Task {
+ try await apiDeleteChatTag(tagId: tagId)
+
+ await MainActor.run {
+ chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
+ if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
+ chatTagsModel.activeFilter = nil
+ }
+ m.chats.forEach { c in
+ if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
+ contact.chatTags = contact.chatTags.filter({ $0 != tagId })
+ m.updateContact(contact)
+ } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
+ group.chatTags = group.chatTags.filter({ $0 != tagId })
+ m.updateGroup(group)
+ }
+ }
+ }
+ }
+ }
+}
+
+private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
+ Task {
+ do {
+ let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
+ let (userTags, chatTags) = try await apiSetChatTags(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ tagIds: tagIds
+ )
+
+ await MainActor.run {
+ let m = ChatModel.shared
+ let tm = ChatTagsModel.shared
+ tm.userTags = userTags
+ if chat.unreadTag, let tags = chat.chatInfo.chatTags {
+ tm.decTagsReadCount(tags)
+ }
+ if var contact = chat.chatInfo.contact {
+ contact.chatTags = chatTags
+ m.updateContact(contact)
+ } else if var group = chat.chatInfo.groupInfo {
+ group.chatTags = chatTags
+ m.updateGroup(group)
+ }
+ ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false)
+ closeSheet()
+ }
+ } catch let error {
+ showAlert(
+ NSLocalizedString("Error saving chat list", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+}
+
+struct EmojiPickerView: UIViewControllerRepresentable {
+ @Binding var selectedEmoji: String?
+ @Binding var showingPicker: Bool
+ @Environment(\.presentationMode) var presentationMode
+
+ class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
+ var parent: EmojiPickerView
+
+ init(parent: EmojiPickerView) {
+ self.parent = parent
+ }
+
+ func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
+ parent.selectedEmoji = emoji?.emoji
+ parent.showingPicker = false
+ picker.dismiss(animated: true)
+ }
+
+ // Called when the picker is dismissed manually (without selection)
+ func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
+ parent.showingPicker = false
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ return Coordinator(parent: self)
+ }
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
+ let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
+
+ picker.presentationController?.delegate = context.coordinator
+
+ let viewController = UIViewController()
+ DispatchQueue.main.async {
+ if let topVC = getTopViewController() {
+ topVC.present(picker, animated: true)
+ }
+ }
+
+ return viewController
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+ // No need to update the controller after creation
+ }
+}
+
+struct TagListEditor: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var chatTagsModel: ChatTagsModel
+ @EnvironmentObject var theme: AppTheme
+ var chat: Chat? = nil
+ var tagId: Int64? = nil
+ var emoji: String?
+ var name: String = ""
+ @State private var newEmoji: String?
+ @State private var newName: String = ""
+ @State private var isPickerPresented = false
+ @State private var saving: Bool?
+
+ var body: some View {
+ VStack {
+ List {
+ let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
+ tag.chatTagId != tagId &&
+ ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
+ }
+
+ Section {
+ HStack {
+ Button {
+ isPickerPresented = true
+ } label: {
+ if let newEmoji {
+ Text(newEmoji)
+ } else {
+ Image(systemName: "face.smiling")
+ .foregroundColor(.secondary)
+ }
+ }
+ TextField("List name...", text: $newName)
+ }
+
+ Button {
+ saving = true
+ if let tId = tagId {
+ updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
+ } else {
+ createChatTag()
+ }
+ } label: {
+ Text(
+ chat != nil
+ ? "Add to list"
+ : "Save list"
+ )
+ }
+ .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
+ } footer: {
+ if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
+ HStack {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ Text("List name and emoji should be different for all lists.")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+ }
+
+ if isPickerPresented {
+ EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .onAppear {
+ newEmoji = emoji
+ newName = name
+ }
+ }
+
+ var trimmedName: String {
+ newName.trimmingCharacters(in: .whitespaces)
+ }
+
+ private func createChatTag() {
+ Task {
+ do {
+ let text = trimmedName
+ let userTags = try await apiCreateChatTag(
+ tag: ChatTagData(emoji: newEmoji , text: text)
+ )
+ await MainActor.run {
+ saving = false
+ chatTagsModel.userTags = userTags
+ }
+ if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
+ setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
+ } else {
+ await MainActor.run { dismiss() }
+ }
+ } catch let error {
+ await MainActor.run {
+ saving = nil
+ showAlert(
+ NSLocalizedString("Error creating list", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ }
+
+ private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
+ Task {
+ do {
+ try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
+ await MainActor.run {
+ saving = false
+ for i in 0.. 375 ? 20 : 16 }
+ private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
var body: some View {
- VStack {
- Spacer().frame(height: 1)
+ let otherUsers: [UserInfo] = m.users
+ .filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
+ .sorted(using: KeyPathComparator(\.user.activeOrder, order: .reverse))
+ let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
+ let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
+ let stopped = m.chatRunning != true
+ VStack(spacing: sectionSpacing) {
+ if let user = m.currentUser {
+ StickyScrollView(resetScroll: $resetScroll) {
+ HStack(spacing: rowPadding) {
+ HStack {
+ ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
+ .padding(.trailing, 6)
+ profileName(user).lineLimit(1)
+ }
+ .padding(rowPadding)
+ .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
+ .modifier(ListRow { activeSheet = .currentProfile })
+ .clipShape(sectionShape)
+ .disabled(stopped)
+ .opacity(stopped ? 0.4 : 1)
+ ForEach(otherUsers) { u in
+ userView(u, size: imageSize)
+ .frame(maxWidth: sectionWidth * 0.618)
+ .fixedSize()
+ .disabled(stopped)
+ .opacity(stopped ? 0.4 : 1)
+ }
+ }
+ .padding(.horizontal, sectionHorizontalPadding)
+ }
+ .frame(height: 2 * rowPadding + imageSize)
+ .padding(.top, sectionSpacing)
+ .overlay(DetermineWidth())
+ .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
+ }
VStack(spacing: 0) {
- ScrollView {
- ScrollViewReader { sp in
- let users = m.users
- .filter({ u in u.user.activeUser || !u.user.hidden })
- .sorted { u, _ in u.user.activeUser }
- VStack(spacing: 0) {
- ForEach(users) { u in
- userView(u)
- Divider()
- if u.user.activeUser { Divider() }
+ openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped)
+ openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped)
+ openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped)
+ openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped)
+ ZStack(alignment: .trailing) {
+ openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
+ Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.monochrome)
+ .foregroundColor(theme.colors.secondary)
+ .frame(maxWidth: 20, maxHeight: .infinity)
+ .padding(.horizontal, rowPadding)
+ .background(Color(.systemBackground).opacity(0.01))
+ .onTapGesture {
+ if (colorScheme == .light) {
+ ThemeManager.applyTheme(systemDarkThemeDefault.get())
+ } else {
+ ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
}
}
- .overlay {
- GeometryReader { geo -> Color in
- DispatchQueue.main.async {
- scrollViewContentSize = geo.size
- let scenes = UIApplication.shared.connectedScenes
- if let windowScene = scenes.first as? UIWindowScene {
- let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame
- disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height
- }
- }
- return Color.clear
- }
+ .onLongPressGesture {
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
- .onChange(of: userPickerVisible) { visible in
- if visible, let u = users.first {
- sp.scrollTo(u.id)
- }
- }
- }
- }
- .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
- .frame(maxHeight: scrollViewContentSize.height)
-
- menuButton("Use from desktop", icon: "desktopcomputer") {
- showConnectDesktop = true
- withAnimation {
- userPickerVisible.toggle()
- }
- }
- Divider()
- menuButton("Settings", icon: "gearshape") {
- showSettings = true
- withAnimation {
- userPickerVisible.toggle()
- }
}
}
+ .clipShape(sectionShape)
+ .padding(.horizontal, sectionHorizontalPadding)
+ .padding(.bottom, sectionSpacing)
}
- .clipShape(RoundedRectangle(cornerRadius: 16))
- .background(
- Rectangle()
- .fill(theme.colors.surface)
- .cornerRadius(16)
- .shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
- )
- .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
- .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
- .padding(8)
- .opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear {
- // This check prevents the call of listUsers after the app is suspended, and the database is closed.
- if case .active = scenePhase {
- Task {
- do {
- let users = try await listUsersAsync()
- await MainActor.run { m.users = users }
- } catch {
- logger.error("Error loading users \(responseError(error))")
- }
- }
- }
- }
- }
-
- private func userView(_ u: UserInfo) -> some View {
- let user = u.user
- return Button(action: {
- if user.activeUser {
- showSettings = true
- withAnimation {
- userPickerVisible.toggle()
- }
- } else {
+ // This check prevents the call of listUsers after the app is suspended, and the database is closed.
+ if case .active = scenePhase {
+ currentUser = m.currentUser?.userId
Task {
do {
- try await changeActiveUserAsync_(user.userId, viewPwd: nil)
- await MainActor.run { userPickerVisible = false }
- } catch {
+ let users = try await listUsersAsync()
await MainActor.run {
- AlertManager.shared.showAlertMsg(
- title: "Error switching profile!",
- message: "Error: \(responseError(error))"
- )
+ m.users = users
+ currentUser = m.currentUser?.userId
}
+ } catch {
+ logger.error("Error loading users \(responseError(error))")
}
}
}
- }, label: {
- HStack(spacing: 0) {
- ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill))
- .padding(.trailing, 12)
- Text(user.chatViewName)
- .fontWeight(user.activeUser ? .medium : .regular)
- .foregroundColor(theme.colors.onBackground)
- .overlay(DetermineWidth())
- Spacer()
- if user.activeUser {
- Image(systemName: "checkmark")
- } else if u.unreadCount > 0 {
- unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
- } else if !user.showNtfs {
- Image(systemName: "speaker.slash")
+ }
+ .onChange(of: userPickerShown) {
+ if !$0 { resetScroll() }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .disabled(switchingProfile)
+ }
+
+ private func userView(_ u: UserInfo, size: CGFloat) -> some View {
+ HStack {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
+ if (u.unreadCount > 0) {
+ UnreadBadge(userInfo: u).offset(x: 4, y: -4)
}
}
- .padding(.trailing)
- .padding([.leading, .vertical], 12)
- })
- .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
- }
-
- private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- HStack(spacing: 0) {
- Text(title)
- .overlay(DetermineWidth())
- Spacer()
- Image(systemName: icon)
- .symbolRenderingMode(.monochrome)
- .foregroundColor(theme.colors.secondary)
- }
- .padding(.horizontal)
- .padding(.vertical, 22)
- .frame(height: menuButtonHeight)
+ .padding(.trailing, 6)
+ Text(u.user.displayName).font(.title2).lineLimit(1)
+ }
+ .padding(rowPadding)
+ .modifier(ListRow {
+ switchingProfile = true
+ Task {
+ do {
+ try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
+ await MainActor.run {
+ switchingProfile = false
+ userPickerShown = false
+ }
+ } catch {
+ await MainActor.run {
+ switchingProfile = false
+ showAlert(
+ NSLocalizedString("Error switching profile!", comment: "alertTitle"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
+ )
+ }
+ }
+ }
+ })
+ .clipShape(sectionShape)
+ }
+
+ private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View {
+ ZStack(alignment: .bottom) {
+ settingsRow(icon, color: theme.colors.secondary) {
+ Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, rowPadding)
+ .padding(.vertical, rowVerticalPadding)
+ .modifier(ListRow { activeSheet = sheet })
+ .disabled(disabled)
+ if showDivider {
+ Divider().padding(.leading, 52)
+ }
}
- .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}
}
-private func unreadCounter(_ unread: Int, color: Color) -> some View {
- unreadCountText(unread)
- .font(.caption)
- .foregroundColor(.white)
- .padding(.horizontal, 4)
- .frame(minWidth: 18, minHeight: 18)
- .background(color)
- .cornerRadius(10)
+struct UnreadBadge: View {
+ var userInfo: UserInfo
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+
+ var body: some View {
+ let size = dynamicSize(userFont).chatInfoSize
+ unreadCountText(userInfo.unreadCount)
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: size, minHeight: size)
+ .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
}
+struct ListRow: ViewModifier {
+ @Environment(\.colorScheme) private var colorScheme: ColorScheme
+ @State private var touchDown = false
+ let action: () -> Void
+
+ func body(content: Content) -> some View {
+ ZStack {
+ elevatedSecondarySystemGroupedBackground
+ Color(.systemGray4).opacity(touchDown ? 1 : 0)
+ content
+ TouchOverlay(touchDown: $touchDown, action: action)
+ }
+ }
+
+ var elevatedSecondarySystemGroupedBackground: Color {
+ switch colorScheme {
+ case .dark: Color(0xFF2C2C2E)
+ default: Color(0xFFFFFFFF)
+ }
+ }
+
+ struct TouchOverlay: UIViewRepresentable {
+ @Binding var touchDown: Bool
+ let action: () -> Void
+
+ func makeUIView(context: Context) -> TouchView {
+ let touchView = TouchView()
+ let gesture = UILongPressGestureRecognizer(
+ target: touchView,
+ action: #selector(touchView.longPress(gesture:))
+ )
+ gesture.delegate = touchView
+ gesture.minimumPressDuration = 0
+ touchView.addGestureRecognizer(gesture)
+ return touchView
+ }
+
+ func updateUIView(_ touchView: TouchView, context: Context) {
+ touchView.representer = self
+ }
+
+ class TouchView: UIView, UIGestureRecognizerDelegate {
+ var representer: TouchOverlay?
+ private var startLocation: CGPoint?
+ private var task: Task?
+
+ @objc
+ func longPress(gesture: UILongPressGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ startLocation = gesture.location(in: nil)
+ task = Task {
+ do {
+ try await Task.sleep(nanoseconds: 200_000000)
+ await MainActor.run { representer?.touchDown = true }
+ } catch { }
+ }
+ case .ended:
+ if hitTest(gesture.location(in: self), with: nil) == self {
+ representer?.action()
+ }
+ task?.cancel()
+ representer?.touchDown = false
+ case .changed:
+ if let startLocation {
+ let location = gesture.location(in: nil)
+ let dx = location.x - startLocation.x
+ let dy = location.y - startLocation.y
+ if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
+ }
+ case .cancelled, .failed:
+ task?.cancel()
+ representer?.touchDown = false
+ default: break
+ }
+ }
+
+ func gestureRecognizer(
+ _: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
+ ) -> Bool { true }
+ }
+ }
+}
+
+
struct UserPicker_Previews: PreviewProvider {
static var previews: some View {
+ @State var activeSheet: UserPickerSheet?
+
let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker(
- showSettings: Binding.constant(false),
- showConnectDesktop: Binding.constant(false),
- userPickerVisible: Binding.constant(true)
+ userPickerShown: .constant(true),
+ activeSheet: $activeSheet
)
.environmentObject(m)
}
diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
index 4b43610236..456c46d318 100644
--- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
+++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
@@ -20,7 +20,7 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false
var body: some View {
- let contactType = chatContactType(chat: chat)
+ let contactType = chatContactType(chat)
Group {
switch (chat.chatInfo) {
@@ -140,9 +140,9 @@ struct ContactListNavLink: View {
}
}
- @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
+ private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
- (
+ return (
contact.verified == true
? verifiedIcon + t
: t
@@ -151,7 +151,7 @@ struct ContactListNavLink: View {
}
private var verifiedIcon: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
@@ -188,8 +188,7 @@ struct ContactListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok {
- ItemsModel.shared.loadOpenChat(contact.id)
- DispatchQueue.main.async {
+ ItemsModel.shared.loadOpenChat(contact.id) {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
diff --git a/apps/ios/Shared/Views/Database/ChatArchiveView.swift b/apps/ios/Shared/Views/Database/ChatArchiveView.swift
deleted file mode 100644
index 3ab4ac9a31..0000000000
--- a/apps/ios/Shared/Views/Database/ChatArchiveView.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-//
-// ChatArchiveView.swift
-// SimpleXChat
-//
-// Created by Evgeny on 23/06/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-struct ChatArchiveView: View {
- @EnvironmentObject var theme: AppTheme
- var archiveName: String
- @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
- @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
- @State private var showDeleteAlert = false
-
- var body: some View {
- let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName)
- let fileTs = chatArchiveTimeDefault.get()
- List {
- Section {
- settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
- Button {
- showShareSheet(items: [fileUrl])
- } label: {
- Text("Save archive")
- }
- }
- settingsRow("trash", color: theme.colors.secondary) {
- Button {
- showDeleteAlert = true
- } label: {
- Text("Delete archive").foregroundColor(.red)
- }
- }
- } header: {
- Text("Chat archive")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("Created on \(fileTs)")
- .foregroundColor(theme.colors.secondary)
- }
- }
- .alert(isPresented: $showDeleteAlert) {
- Alert(
- title: Text("Delete chat archive?"),
- primaryButton: .destructive(Text("Delete")) {
- do {
- try FileManager.default.removeItem(atPath: fileUrl.path)
- chatArchiveName = nil
- chatArchiveTime = 0
- } catch let error {
- logger.error("removeItem error \(String(describing: error))")
- }
- },
- secondaryButton: .cancel()
- )
- }
- }
-}
-
-struct ChatArchiveView_Previews: PreviewProvider {
- static var previews: some View {
- ChatArchiveView(archiveName: "")
- }
-}
diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
index be167b92b9..441a164f8a 100644
--- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
@@ -48,6 +48,8 @@ struct DatabaseEncryptionView: View {
@State private var confirmNewKey = ""
@State private var currentKeyShown = false
+ let stopChatRunBlockStartChat: (Binding, @escaping () async throws -> Bool) -> Void
+
var body: some View {
ZStack {
List {
@@ -134,46 +136,61 @@ struct DatabaseEncryptionView: View {
.onAppear {
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
}
- .disabled(m.chatRunning != false)
+ .disabled(progressIndicator)
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
}
- private func encryptDatabase() {
- progressIndicator = true
- Task {
- do {
- encryptionStartedDefault.set(true)
- encryptionStartedAtDefault.set(Date.now)
- if !m.chatDbChanged {
- try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
- }
- try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
- encryptionStartedDefault.set(false)
- initialRandomDBPassphraseGroupDefault.set(false)
- if migration {
- storeDBPassphraseGroupDefault.set(useKeychain)
- }
- if useKeychain {
- if kcDatabasePassword.set(newKey) {
- await resetFormAfterEncryption(true)
- await operationEnded(.databaseEncrypted)
- } else {
- await resetFormAfterEncryption()
- await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
- }
- } else {
- if migration {
- removePassphraseFromKeyChain()
- }
- await resetFormAfterEncryption()
+ private func encryptDatabaseAsync() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ encryptionStartedDefault.set(true)
+ encryptionStartedAtDefault.set(Date.now)
+ if !m.chatDbChanged {
+ try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
+ }
+ try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
+ encryptionStartedDefault.set(false)
+ initialRandomDBPassphraseGroupDefault.set(false)
+ if migration {
+ storeDBPassphraseGroupDefault.set(useKeychain)
+ }
+ if useKeychain {
+ if kcDatabasePassword.set(newKey) {
+ await resetFormAfterEncryption(true)
await operationEnded(.databaseEncrypted)
- }
- } catch let error {
- if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
- await operationEnded(.currentPassphraseError)
} else {
- await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
+ await resetFormAfterEncryption()
+ await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
}
+ } else {
+ if migration {
+ removePassphraseFromKeyChain()
+ }
+ await resetFormAfterEncryption()
+ await operationEnded(.databaseEncrypted)
+ }
+ return true
+ } catch let error {
+ if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError {
+ await operationEnded(.currentPassphraseError)
+ } else {
+ await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
+ }
+ return false
+ }
+ }
+
+ private func encryptDatabase() {
+ // it will try to stop and start the chat in case of: non-migration && successful encryption. In migration the chat will remain stopped
+ if migration {
+ Task {
+ await encryptDatabaseAsync()
+ }
+ } else {
+ stopChatRunBlockStartChat($progressIndicator) {
+ return await encryptDatabaseAsync()
}
}
}
@@ -371,6 +388,6 @@ func validKey(_ s: String) -> Bool {
struct DatabaseEncryptionView_Previews: PreviewProvider {
static var previews: some View {
- DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
+ DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false, stopChatRunBlockStartChat: { _, _ in true })
}
}
diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
index 9d71e2a788..02a1b87826 100644
--- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct DatabaseErrorView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
@State var status: DBMigrationResult
@State private var dbKey = ""
@State private var storedDBKey = kcDatabasePassword.get()
@@ -27,24 +28,40 @@ struct DatabaseErrorView: View {
}
}
- @ViewBuilder private func databaseErrorView() -> some View {
- VStack(alignment: .leading, spacing: 16) {
+ private func databaseErrorView() -> some View {
+ VStack(alignment: .center, spacing: 20) {
switch status {
case let .errorNotADatabase(dbFile):
if useKeychain && storedDBKey != nil && storedDBKey != "" {
titleText("Wrong database passphrase")
Text("Database passphrase is different from saved in the keychain.")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+
databaseKeyField(onSubmit: saveAndRunChat)
- saveAndOpenButton()
- fileNameText(dbFile)
+ Spacer()
+ VStack(spacing: 10) {
+ saveAndOpenButton()
+ fileNameText(dbFile)
+ }
} else {
titleText("Encrypted database")
Text("Database passphrase is required to open chat.")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+ .padding(.bottom, 5)
+
if useKeychain {
databaseKeyField(onSubmit: saveAndRunChat)
+ Spacer()
saveAndOpenButton()
} else {
databaseKeyField(onSubmit: { runChat() })
+ Spacer()
openChatButton()
}
}
@@ -52,73 +69,105 @@ struct DatabaseErrorView: View {
switch migrationError {
case let .upgrade(upMigrations):
titleText("Database upgrade")
- Button("Upgrade and open chat") { runChat(confirmMigrations: .yesUp) }
- fileNameText(dbFile)
migrationsText(upMigrations.map(\.upName))
+ Spacer()
+ VStack(spacing: 10) {
+ Button("Upgrade and open chat") {
+ runChat(confirmMigrations: .yesUp)
+ }.buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ fileNameText(dbFile)
+ }
case let .downgrade(downMigrations):
titleText("Database downgrade")
- Text("Warning: you may lose some data!").bold()
- Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
- fileNameText(dbFile)
+ Text("Warning: you may lose some data!")
+ .bold()
+ .padding(.horizontal, 25)
+ .multilineTextAlignment(.center)
+
migrationsText(downMigrations)
+ Spacer()
+ VStack(spacing: 10) {
+ Button("Downgrade and open chat") {
+ runChat(confirmMigrations: .yesUpDown)
+ }.buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ fileNameText(dbFile)
+ }
case let .migrationError(mtrError):
titleText("Incompatible database version")
- fileNameText(dbFile)
- Text("Error: ") + Text(mtrErrorDescription(mtrError))
+ fileNameText(dbFile, font: .callout)
+ errorView(Text(mtrErrorDescription(mtrError)))
}
case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error")
- fileNameText(dbFile)
- Text("Error: \(migrationSQLError)")
+ fileNameText(dbFile, font: .callout)
+ errorView(Text("Error: \(migrationSQLError)"))
case .errorKeychain:
titleText("Keychain error")
- Text("Cannot access keychain to save database password")
+ errorView(Text("Cannot access keychain to save database password"))
case .invalidConfirmation:
// this can only happen if incorrect parameter is passed
- Text(String("Invalid migration confirmation")).font(.title)
+ titleText("Invalid migration confirmation")
+ errorView()
+
case let .unknown(json):
titleText("Database error")
- Text("Unknown database error: \(json)")
+ errorView(Text("Unknown database error: \(json)"))
case .ok:
EmptyView()
}
if showRestoreDbButton {
- Spacer().frame(height: 10)
+ Spacer()
Text("The attempt to change database passphrase was not completed.")
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+ .font(.footnote)
+
restoreDbButton()
}
}
- .padding()
+ .padding(.horizontal, 25)
+ .padding(.top, 75)
+ .padding(.bottom, 25)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
}
- private func titleText(_ s: LocalizedStringKey) -> Text {
- Text(s).font(.title)
+ private func titleText(_ s: LocalizedStringKey) -> some View {
+ Text(s).font(.largeTitle).bold().multilineTextAlignment(.center)
}
- private func fileNameText(_ f: String) -> Text {
- Text("File: \((f as NSString).lastPathComponent)")
+ private func fileNameText(_ f: String, font: Font = .caption) -> Text {
+ Text("File: \((f as NSString).lastPathComponent)").font(font)
}
- private func migrationsText(_ ms: [String]) -> Text {
- Text("Migrations: \(ms.joined(separator: ", "))")
+ private func migrationsText(_ ms: [String]) -> some View {
+ (Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
+ .padding(.vertical, 10)
+ .padding(.horizontal)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(uiColor: .tertiarySystemFill))
+ )
}
private func saveAndOpenButton() -> some View {
Button("Save passphrase and open chat") {
saveAndRunChat()
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func openChatButton() -> some View {
Button("Open chat") {
runChat()
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func saveAndRunChat() {
@@ -192,8 +241,9 @@ struct DatabaseErrorView: View {
secondaryButton: .cancel()
))
} label: {
- Text("Restore database backup").foregroundColor(.red)
+ Text("Restore database backup")
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func restoreDb() {
@@ -208,6 +258,23 @@ struct DatabaseErrorView: View {
))
}
}
+
+ private func errorView(_ s: Text? = nil) -> some View {
+ VStack(spacing: 35) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .frame(width: 50, height: 50)
+ .foregroundColor(.red)
+
+ if let text = s {
+ text
+ .multilineTextAlignment(.center)
+ .font(.footnote)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ }
}
struct DatabaseErrorView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift
index f5b5287971..59eee1338b 100644
--- a/apps/ios/Shared/Views/Database/DatabaseView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseView.swift
@@ -44,8 +44,9 @@ enum DatabaseAlert: Identifiable {
struct DatabaseView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
+ let dismissSettingsSheet: DismissAction
@State private var runChat = false
+ @State private var stoppingChat = false
@State private var alert: DatabaseAlert? = nil
@State private var showFileImporter = false
@State private var importedArchivePath: URL?
@@ -57,6 +58,8 @@ struct DatabaseView: View {
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var appFilesCountAndSize: (Int, Int)?
+ @State private var showDatabaseEncryptionView = false
+
@State var chatItemTTL: ChatItemTTL
@State private var currentChatItemTTL: ChatItemTTL = .none
@@ -69,7 +72,20 @@ struct DatabaseView: View {
}
}
+ @ViewBuilder
private func chatDatabaseView() -> some View {
+ NavigationLink(isActive: $showDatabaseEncryptionView) {
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
+ stopChatRunBlockStartChat(false, progressIndicator, block)
+ })
+ .navigationTitle("Database passphrase")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+
List {
let stopped = m.chatRunning == false
Section {
@@ -101,9 +117,10 @@ struct DatabaseView: View {
isOn: $runChat
)
.onChange(of: runChat) { _ in
- if (runChat) {
- startChat()
- } else {
+ if runChat {
+ DatabaseView.startChat($runChat, $progressIndicator)
+ } else if !stoppingChat {
+ stoppingChat = false
alert = .stopChat
}
}
@@ -123,7 +140,9 @@ struct DatabaseView: View {
let color: Color = unencrypted ? .orange : theme.colors.secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink {
- DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
+ stopChatRunBlockStartChat(false, progressIndicator, block)
+ })
.navigationTitle("Database passphrase")
.modifier(ThemedBackground(grouped: true))
} label: {
@@ -133,9 +152,14 @@ struct DatabaseView: View {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
- alert = .exportProhibited
+ showDatabaseEncryptionView = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ alert = .exportProhibited
+ }
} else {
- exportArchive()
+ stopChatRunBlockStartChat(stopped, $progressIndicator) {
+ await exportArchive()
+ }
}
}
}
@@ -144,20 +168,6 @@ struct DatabaseView: View {
showFileImporter = true
}
}
- if let archiveName = chatArchiveName {
- let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
- ? "Old database archive"
- : "New database archive"
- settingsRow("archivebox", color: theme.colors.secondary) {
- NavigationLink {
- ChatArchiveView(archiveName: archiveName)
- .navigationTitle(title)
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text(title)
- }
- }
- }
settingsRow("trash.slash", color: theme.colors.secondary) {
Button("Delete database", role: .destructive) {
alert = .deleteChat
@@ -167,14 +177,10 @@ struct DatabaseView: View {
Text("Chat database")
.foregroundColor(theme.colors.secondary)
} footer: {
- Text(
- stopped
- ? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
- : "Stop chat to enable database actions"
- )
+ Text("You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.")
.foregroundColor(theme.colors.secondary)
}
- .disabled(!stopped)
+ .disabled(progressIndicator)
if case .group = dbContainer, legacyDatabase {
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
@@ -190,7 +196,7 @@ struct DatabaseView: View {
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
alert = .deleteFilesAndMedia
}
- .disabled(!stopped || appFilesCountAndSize?.0 == 0)
+ .disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
} header: {
Text("Files & media")
.foregroundColor(theme.colors.secondary)
@@ -255,7 +261,9 @@ struct DatabaseView: View {
title: Text("Import chat database?"),
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Import")) {
- importArchive(fileURL)
+ stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
+ await DatabaseView.importArchive(fileURL, $progressIndicator, $alert, false)
+ }
},
secondaryButton: .cancel()
)
@@ -263,19 +271,15 @@ struct DatabaseView: View {
return Alert(title: Text("Error: no database file"))
}
case .archiveImported:
- return Alert(
- title: Text("Chat database imported"),
- message: Text("Restart the app to use imported chat database")
- )
+ let (title, message) = archiveImportedAlertText()
+ return Alert(title: Text(title), message: Text(message))
case let .archiveImportedWithErrors(errs):
- return Alert(
- title: Text("Chat database imported"),
- message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
- )
+ let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
+ return Alert(title: Text(title), message: Text(message))
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
+ message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath])
}
@@ -285,15 +289,17 @@ struct DatabaseView: View {
title: Text("Delete chat profile?"),
message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Delete")) {
- deleteChat()
+ let wasStopped = m.chatRunning == false
+ stopChatRunBlockStartChat(wasStopped, $progressIndicator) {
+ _ = await deleteChat()
+ return true
+ }
},
secondaryButton: .cancel()
)
case .chatDeleted:
- return Alert(
- title: Text("Chat database deleted"),
- message: Text("Restart the app to create a new chat profile")
- )
+ let (title, message) = chatDeletedAlertText()
+ return Alert(title: Text(title), message: Text(message))
case .deleteLegacyDatabase:
return Alert(
title: Text("Delete old database?"),
@@ -308,7 +314,10 @@ struct DatabaseView: View {
title: Text("Delete files and media?"),
message: Text("This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain."),
primaryButton: .destructive(Text("Delete")) {
- deleteFiles()
+ stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
+ deleteFiles()
+ return true
+ }
},
secondaryButton: .cancel()
)
@@ -328,95 +337,184 @@ struct DatabaseView: View {
}
}
- private func authStopChat() {
+ private func authStopChat(_ onStop: (() -> Void)? = nil) {
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
switch laResult {
- case .success: stopChat()
- case .unavailable: stopChat()
+ case .success: stopChat(onStop)
+ case .unavailable: stopChat(onStop)
case .failed: withAnimation { runChat = true }
}
}
} else {
- stopChat()
+ stopChat(onStop)
}
}
- private func stopChat() {
+ private func stopChat(_ onStop: (() -> Void)? = nil) {
Task {
do {
try await stopChatAsync()
+ onStop?()
} catch let error {
await MainActor.run {
runChat = true
- alert = .error(title: "Error stopping chat", error: responseError(error))
+ showAlert("Error stopping chat", message: responseError(error))
}
}
}
}
- private func exportArchive() {
- progressIndicator = true
- Task {
- do {
- let (archivePath, archiveErrors) = try await exportChatArchive()
- if archiveErrors.isEmpty {
- showShareSheet(items: [archivePath])
- await MainActor.run { progressIndicator = false }
- } else {
- await MainActor.run {
- alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
- progressIndicator = false
+ func stopChatRunBlockStartChat(
+ _ stopped: Bool,
+ _ progressIndicator: Binding,
+ _ block: @escaping () async throws -> Bool
+ ) {
+ // if the chat was running, the sequence is: stop chat, run block, start chat.
+ // Otherwise, just run block and do nothing - the toggle will be visible anyway and the user can start the chat or not
+ if stopped {
+ Task {
+ do {
+ _ = try await block()
+ } catch {
+ logger.error("Error while executing block: \(error)")
+ }
+ }
+ } else {
+ authStopChat {
+ stoppingChat = true
+ runChat = false
+ Task {
+ // if it throws, let's start chat again anyway
+ var canStart = false
+ do {
+ canStart = try await block()
+ } catch {
+ logger.error("Error executing block: \(error)")
+ canStart = true
+ }
+ if canStart {
+ await MainActor.run {
+ DatabaseView.startChat($runChat, $progressIndicator)
+ }
}
}
+ }
+ }
+ }
+
+ static func startChat(_ runChat: Binding, _ progressIndicator: Binding) {
+ progressIndicator.wrappedValue = true
+ let m = ChatModel.shared
+ if m.chatDbChanged {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ resetChatCtrl()
+ do {
+ let hadDatabase = hasDatabase()
+ try initializeChat(start: true)
+ m.chatDbChanged = false
+ AppChatState.shared.set(.active)
+ if m.chatDbStatus != .ok || !hadDatabase {
+ // Hide current view and show `DatabaseErrorView`
+ dismissAllSheets(animated: true)
+ }
+ } catch let error {
+ fatalError("Error starting chat \(responseError(error))")
+ }
+ progressIndicator.wrappedValue = false
+ }
+ } else {
+ do {
+ _ = try apiStartChat()
+ runChat.wrappedValue = true
+ m.chatRunning = true
+ ChatReceiver.shared.start()
+ chatLastStartGroupDefault.set(Date.now)
+ AppChatState.shared.set(.active)
} catch let error {
+ runChat.wrappedValue = false
+ showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
+ }
+ progressIndicator.wrappedValue = false
+ }
+ }
+
+ private func exportArchive() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ let (archivePath, archiveErrors) = try await exportChatArchive()
+ if archiveErrors.isEmpty {
+ showShareSheet(items: [archivePath])
+ await MainActor.run { progressIndicator = false }
+ } else {
await MainActor.run {
- alert = .error(title: "Error exporting chat database", error: responseError(error))
+ alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
progressIndicator = false
}
}
+ } catch let error {
+ await MainActor.run {
+ alert = .error(title: "Error exporting chat database", error: responseError(error))
+ progressIndicator = false
+ }
}
+ return false
}
- private func importArchive(_ archivePath: URL) {
+ static func importArchive(
+ _ archivePath: URL,
+ _ progressIndicator: Binding,
+ _ alert: Binding,
+ _ migration: Bool
+ ) async -> Bool {
if archivePath.startAccessingSecurityScopedResource() {
- progressIndicator = true
- Task {
- do {
- try await apiDeleteStorage()
- try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
- do {
- let config = ArchiveConfig(archivePath: archivePath.path)
- let archiveErrors = try await apiImportArchive(config: config)
- _ = kcDatabasePassword.remove()
- if archiveErrors.isEmpty {
- await operationEnded(.archiveImported)
- } else {
- await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors))
- }
- } catch let error {
- await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
- }
- } catch let error {
- await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
- }
+ defer {
archivePath.stopAccessingSecurityScopedResource()
}
+ await MainActor.run {
+ progressIndicator.wrappedValue = true
+ }
+ do {
+ try await apiDeleteStorage()
+ try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
+ do {
+ let config = ArchiveConfig(archivePath: archivePath.path)
+ let archiveErrors = try await apiImportArchive(config: config)
+ shouldImportAppSettingsDefault.set(true)
+ _ = kcDatabasePassword.remove()
+ if archiveErrors.isEmpty {
+ await operationEnded(.archiveImported, progressIndicator, alert)
+ return true
+ } else {
+ await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert)
+ return migration
+ }
+ } catch let error {
+ await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
+ }
+ } catch let error {
+ await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
+ }
} else {
- alert = .error(title: "Error accessing database file")
+ showAlert("Error accessing database file")
}
+ return false
}
- private func deleteChat() {
- progressIndicator = true
- Task {
- do {
- try await deleteChatAsync()
- await operationEnded(.chatDeleted)
- appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
- } catch let error {
- await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
- }
+ private func deleteChat() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ try await deleteChatAsync()
+ appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
+ await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
+ return true
+ } catch let error {
+ await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
+ return false
}
}
@@ -428,39 +526,30 @@ struct DatabaseView: View {
}
}
- private func operationEnded(_ dbAlert: DatabaseAlert) async {
+ private static func operationEnded(_ dbAlert: DatabaseAlert, _ progressIndicator: Binding, _ alert: Binding) async {
await MainActor.run {
+ let m = ChatModel.shared
m.chatDbChanged = true
m.chatInitialized = false
- progressIndicator = false
- alert = dbAlert
+ progressIndicator.wrappedValue = false
}
- }
-
- private func startChat() {
- if m.chatDbChanged {
- showSettings = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- resetChatCtrl()
- do {
- try initializeChat(start: true)
- m.chatDbChanged = false
- AppChatState.shared.set(.active)
- } catch let error {
- fatalError("Error starting chat \(responseError(error))")
- }
- }
- } else {
- do {
- _ = try apiStartChat()
- runChat = true
- m.chatRunning = true
- ChatReceiver.shared.start()
- chatLastStartGroupDefault.set(Date.now)
- AppChatState.shared.set(.active)
- } catch let error {
- runChat = false
- alert = .error(title: "Error starting chat", error: responseError(error))
+ await withCheckedContinuation { cont in
+ let okAlertActionWaiting = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default, handler: { _ in cont.resume() })
+ // show these alerts globally so they are visible when all sheets will be hidden
+ if case .archiveImported = dbAlert {
+ let (title, message) = archiveImportedAlertText()
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else if case .archiveImportedWithErrors(let errs) = dbAlert {
+ let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else if case .chatDeleted = dbAlert {
+ let (title, message) = chatDeletedAlertText()
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else if case let .error(title, error) = dbAlert {
+ showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] })
+ } else {
+ alert.wrappedValue = dbAlert
+ cont.resume()
}
}
}
@@ -503,8 +592,28 @@ struct DatabaseView: View {
}
}
-func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
- return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
+func archiveImportedAlertText() -> (String, String) {
+ (
+ NSLocalizedString("Chat database imported", comment: ""),
+ NSLocalizedString("Restart the app to use imported chat database", comment: "")
+ )
+}
+func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
+ (
+ NSLocalizedString("Chat database imported", comment: ""),
+ NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs)
+ )
+}
+
+private func chatDeletedAlertText() -> (String, String) {
+ (
+ NSLocalizedString("Chat database deleted", comment: ""),
+ NSLocalizedString("Restart the app to create a new chat profile", comment: "")
+ )
+}
+
+func archiveErrorsText(_ errs: [ArchiveError]) -> String {
+ return "\n" + errs.map(showArchiveError).joined(separator: "\n")
func showArchiveError(_ err: ArchiveError) -> String {
switch err {
@@ -533,7 +642,9 @@ func deleteChatAsync() async throws {
}
struct DatabaseView_Previews: PreviewProvider {
+ @Environment(\.dismiss) static var mockDismiss
+
static var previews: some View {
- DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
+ DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none)
}
}
diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
index e79f24c6d9..79c0a42ae0 100644
--- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
+++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
@@ -117,7 +117,7 @@ struct MigrateToAppGroupView: View {
setV3DBMigration(.migration_error)
migrationError = "Error starting chat: \(responseError(error))"
}
- deleteOldArchive()
+ deleteOldChatArchive()
} label: {
Text("Start chat")
.font(.title)
@@ -235,14 +235,16 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
let errs = try await apiExportArchive(config: config)
if storagePath == nil {
- deleteOldArchive()
+ deleteOldChatArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime)
}
return (archivePath, errs)
}
-func deleteOldArchive() {
+/// Deprecated. Remove in the end of 2025. All unused archives should be deleted for the most users til then.
+/// Remove DEFAULT_CHAT_ARCHIVE_NAME and DEFAULT_CHAT_ARCHIVE_TIME as well
+func deleteOldChatArchive() {
let d = UserDefaults.standard
if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) {
do {
diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift
index 0ade1c0d8e..1e334367e8 100644
--- a/apps/ios/Shared/Views/Helpers/AppSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift
@@ -11,6 +11,12 @@ import SwiftUI
class AppSheetState: ObservableObject {
static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false
+
+ func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
+ !protectScreen || scenePhaseActive
+ ? RedactionReasons()
+ : RedactionReasons.placeholder
+ }
}
private struct PrivacySensitive: ViewModifier {
@@ -19,11 +25,7 @@ private struct PrivacySensitive: ViewModifier {
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
func body(content: Content) -> some View {
- if !protectScreen {
- content
- } else {
- content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
- }
+ content.redacted(reason: appSheetState.redactionReasons(protectScreen))
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
index 477dc567eb..9aa6ac86cf 100644
--- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
@@ -14,50 +14,55 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
- struct ClipShape: Shape {
- let maxCornerRadius: Double
-
- func path(in rect: CGRect) -> Path {
- Path(
- roundedRect: rect,
- cornerRadius: min((rect.height / 2), maxCornerRadius),
- style: .circular
- )
- }
- }
-
+ @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
+ @AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
+ private let chatItem: (content: CIContent, chatDir: CIDirection)?
+ private let tailVisible: Bool
+
init() {
- clipShape = ClipShape(
- maxCornerRadius: 18
- )
+ self.chatItem = nil
+ self.tailVisible = false
+ }
+
+ init(_ ci: ChatItem, tailVisible: Bool) {
+ self.chatItem = (ci.content, ci.chatDir)
+ self.tailVisible = tailVisible
}
- init(_ chatItem: ChatItem) {
- clipShape = ClipShape(
- maxCornerRadius: {
- switch chatItem.content {
- case
- .sndMsgContent,
+ private func shapeStyle() -> ChatItemShape.Style {
+ if let ci = chatItem {
+ switch ci.content {
+ case
+ .sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
- .rcvGroupInvitation,
- .sndGroupInvitation,
- .sndDeleted,
- .rcvDeleted,
.rcvIntegrityError,
- .sndModerated,
- .rcvModerated,
- .rcvBlocked,
- .invalidJSON: 18
- default: 8
+ .invalidJSON:
+ let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
+ false
+ } else {
+ tailVisible
}
- }()
- )
+ return tailEnabled
+ ? .bubble(
+ padding: ci.chatDir.sent ? .trailing : .leading,
+ tailVisible: tail
+ )
+ : .roundRect(radius: msgRectMaxRadius)
+ case .rcvGroupInvitation, .sndGroupInvitation:
+ return .roundRect(radius: msgRectMaxRadius)
+ default: return .roundRect(radius: 8)
+ }
+ } else {
+ return .roundRect(radius: msgRectMaxRadius)
+ }
}
-
- private let clipShape: ClipShape
-
+
func body(content: Content) -> some View {
+ let clipShape = ChatItemShape(
+ roundness: roundness,
+ style: shapeStyle()
+ )
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
@@ -65,4 +70,106 @@ struct ChatItemClipped: ViewModifier {
}
}
+struct ChatTailPadding: ViewModifier {
+ func body(content: Content) -> some View {
+ content.padding(.horizontal, -msgTailWidth)
+ }
+}
+private let msgRectMaxRadius: Double = 18
+private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
+private let msgTailWidth: Double = 9
+private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
+private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
+
+struct ChatItemShape: Shape {
+ fileprivate enum Style {
+ case bubble(padding: HorizontalEdge, tailVisible: Bool)
+ case roundRect(radius: Double)
+ }
+
+ fileprivate let roundness: Double
+ fileprivate let style: Style
+
+ func path(in rect: CGRect) -> Path {
+ switch style {
+ case let .bubble(padding, tailVisible):
+ let w = rect.width
+ let h = rect.height
+ let rxMax = min(msgBubbleMaxRadius, w / 2)
+ let ryMax = min(msgBubbleMaxRadius, h / 2)
+ let rx = roundness * rxMax
+ let ry = roundness * ryMax
+ let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
+ var path = Path()
+ // top side
+ path.move(to: CGPoint(x: rx, y: 0))
+ path.addLine(to: CGPoint(x: w - rx, y: 0))
+ if roundness > 0 {
+ // top-right corner
+ path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
+ }
+ if rect.height > 2 * ry {
+ // right side
+ path.addLine(to: CGPoint(x: w, y: h - ry))
+ }
+ if roundness > 0 {
+ // bottom-right corner
+ path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
+ }
+ // bottom side
+ if tailVisible {
+ path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
+ if roundness > 0 {
+ // bottom-left tail
+ // distance of control point from touch point, calculated via ratios
+ let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
+ // tail control point
+ let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
+ // bottom-left tail curve
+ path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
+ } else {
+ path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
+ }
+ if rect.height > ry + tailHeight {
+ // left side
+ path.addLine(to: CGPoint(x: 0, y: ry))
+ }
+ } else {
+ path.addLine(to: CGPoint(x: rx, y: h))
+ path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
+ if rect.height > 2 * ry {
+ // left side
+ path.addLine(to: CGPoint(x: 0, y: ry))
+ }
+ }
+ if roundness > 0 {
+ // top-left corner
+ path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
+ }
+ path.closeSubpath()
+ return switch padding {
+ case .leading: path
+ case .trailing: path
+ .scale(x: -1, y: 1, anchor: .center)
+ .path(in: rect)
+ }
+ case let .roundRect(radius):
+ return Path(roundedRect: rect, cornerRadius: radius * roundness)
+ }
+ }
+
+ var offset: Double? {
+ switch style {
+ case let .bubble(padding, isTailVisible):
+ if isTailVisible {
+ switch padding {
+ case .leading: -msgTailWidth
+ case .trailing: msgTailWidth
+ }
+ } else { 0 }
+ case .roundRect: 0
+ }
+ }
+
+}
diff --git a/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift
new file mode 100644
index 0000000000..dca413dafe
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift
@@ -0,0 +1,21 @@
+//
+// Test.swift
+// SimpleX (iOS)
+//
+// Created by Levitating Pineapple on 31/08/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+extension View {
+ @ViewBuilder
+ func invertedForegroundStyle(enabled: Bool = true) -> some View {
+ if enabled {
+ foregroundStyle(Material.ultraThin)
+ .environment(\.colorScheme, .dark)
+ .grayscale(1)
+ .contrast(-20)
+ } else { self }
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
index 248504c59b..3eedd56441 100644
--- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -20,7 +20,7 @@ struct ProfileImage: View {
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
- if let uiImage = UIImage(base64Encoded: imageStr) {
+ if let uiImage = imageFromBase64(imageStr) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 936c6cb3ab..b8de0e4ceb 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -8,15 +8,63 @@
import SwiftUI
-func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
+func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
- let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
+ let rootViewController = keyWindow.rootViewController {
+ // Find the top-most presented view controller
+ var topController = rootViewController
+ while let presentedViewController = topController.presentedViewController {
+ topController = presentedViewController
+ }
+ return topController
+ }
+ return nil
+}
+
+func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
+ if let topController = getTopViewController() {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let completed = completed {
- let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
- activityViewController.completionWithItemsHandler = handler
- }
- presentedViewController.present(activityViewController, animated: true)
+ activityViewController.completionWithItemsHandler = { _, _, _, _ in
+ completed()
+ }
+ }
+ topController.present(activityViewController, animated: true)
}
}
+
+func showAlert(
+ title: String,
+ message: String? = nil,
+ buttonTitle: String,
+ buttonAction: @escaping () -> Void,
+ cancelButton: Bool
+) -> Void {
+ if let topController = getTopViewController() {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
+ buttonAction()
+ })
+ if cancelButton {
+ alert.addAction(cancelAlertAction)
+ }
+ topController.present(alert, animated: true)
+ }
+}
+
+func showAlert(
+ _ title: String,
+ message: String? = nil,
+ actions: () -> [UIAlertAction] = { [okAlertAction] }
+) {
+ if let topController = getTopViewController() {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ for action in actions() { alert.addAction(action) }
+ topController.present(alert, animated: true)
+ }
+}
+
+let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
+
+let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
diff --git a/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift
new file mode 100644
index 0000000000..841d5c7eda
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift
@@ -0,0 +1,188 @@
+//
+// SwiftUISheet.swift
+// SimpleX (iOS)
+//
+// Created by user on 23/09/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+private let sheetAnimationDuration: Double = 0.35
+
+// Refrence: https://easings.net/
+private let easeOutCubic = UICubicTimingParameters(
+ controlPoint1: CGPoint(x: 0.215, y: 0.61),
+ controlPoint2: CGPoint(x: 0.355, y: 1)
+)
+
+struct Sheet: ViewModifier {
+ @Binding var isPresented: Bool
+ @ViewBuilder let sheetContent: () -> SheetContent
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+ SheetRepresentable(isPresented: $isPresented, content: sheetContent())
+ .allowsHitTesting(isPresented)
+ .ignoresSafeArea()
+ }
+ }
+}
+
+struct SheetRepresentable: UIViewControllerRepresentable {
+ @Binding var isPresented: Bool
+ let content: Content
+
+ func makeUIViewController(context: Context) -> Controller {
+ Controller(content: content, representer: self)
+ }
+
+ func updateUIViewController(_ sheetController: Controller, context: Context) {
+ sheetController.animate(isPresented: isPresented)
+ }
+
+ class Controller: UIViewController {
+ let hostingController: UIHostingController
+ private let animator = UIViewPropertyAnimator(
+ duration: sheetAnimationDuration,
+ timingParameters: easeOutCubic
+ )
+ private let representer: SheetRepresentable
+ private var retainedFraction: CGFloat = 0
+ private var sheetHeight: Double { hostingController.view.frame.height }
+ private var task: Task?
+
+ init(content: C, representer: SheetRepresentable) {
+ self.representer = representer
+ self.hostingController = UIHostingController(rootView: content)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
+
+ deinit {
+ animator.stopAnimation(true)
+ animator.finishAnimation(at: .current)
+ }
+
+ func animate(isPresented: Bool) {
+ let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed
+ let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
+ let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1)
+
+ if !isPresented && sheetFullyDismissed ||
+ isPresented && sheetFullyPresented ||
+ alreadyAnimating {
+ return
+ }
+
+ animator.pauseAnimation()
+ animator.isReversed = !isPresented
+ animator.continueAnimation(
+ withTimingParameters: isPresented
+ ? easeOutCubic
+ : UICubicTimingParameters(animationCurve: .easeIn),
+ durationFactor: 1 - animator.fractionComplete
+ )
+ handleVisibility()
+ }
+
+ func handleVisibility() {
+ if animator.isReversed {
+ task = Task {
+ do {
+ let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC))
+ try await Task.sleep(nanoseconds: sleepDuration)
+ view.isHidden = true
+ } catch { }
+ }
+ } else {
+ task?.cancel()
+ task = nil
+ view.isHidden = false
+ }
+ }
+
+ override func viewDidLoad() {
+ view.isHidden = true
+ view.backgroundColor = .clear
+ view.addGestureRecognizer(
+ UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
+ )
+ addChild(hostingController)
+ hostingController.didMove(toParent: self)
+ if let sheet = hostingController.view {
+ sheet.isHidden = true
+ sheet.clipsToBounds = true
+ sheet.layer.cornerRadius = 10
+ sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
+ sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
+ sheet.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(sheet)
+ NSLayoutConstraint.activate([
+ hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ // Ensures animations are only setup once
+ // on some iOS version `viewDidAppear` can get called on each state change.
+ if hostingController.view.isHidden {
+ hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight)
+ hostingController.view.isHidden = false
+ animator.pausesOnCompletion = true
+ animator.addAnimations {
+ self.hostingController.view.transform = .identity
+ self.view.backgroundColor = UIColor {
+ switch $0.userInterfaceStyle {
+ case .dark: .black.withAlphaComponent(0.290)
+ default: .black.withAlphaComponent(0.121)
+ }
+ }
+ }
+ animator.startAnimation()
+ animator.pauseAnimation()
+ }
+ }
+
+ @objc
+ func pan(gesture: UIPanGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ animator.isReversed = false
+ animator.pauseAnimation()
+ retainedFraction = animator.fractionComplete
+ case .changed:
+ animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight
+ case .ended, .cancelled:
+ let velocity = gesture.velocity(in: view).y
+ animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus
+ let defaultVelocity = sheetHeight / sheetAnimationDuration
+ let fractionRemaining = 1 - animator.fractionComplete
+ let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1)
+ animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining)
+ handleVisibility()
+ DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
+ self.representer.isPresented = !self.animator.isReversed
+ }
+ default: break
+ }
+ }
+
+ @objc
+ func tap(gesture: UITapGestureRecognizer) {
+ switch gesture.state {
+ case .ended:
+ if gesture.location(in: view).y < view.frame.height - sheetHeight {
+ representer.isPresented = false
+ }
+ default: break
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/StickyScrollView.swift b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift
new file mode 100644
index 0000000000..5799962778
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift
@@ -0,0 +1,61 @@
+//
+// StickyScrollView.swift
+// SimpleX (iOS)
+//
+// Created by user on 20/09/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct StickyScrollView: UIViewRepresentable {
+ @Binding var resetScroll: ResetScrollAction
+ @ViewBuilder let content: () -> Content
+
+ func makeUIView(context: Context) -> UIScrollView {
+ let hc = context.coordinator.hostingController
+ hc.view.backgroundColor = .clear
+ let sv = UIScrollView()
+ sv.showsHorizontalScrollIndicator = false
+ sv.addSubview(hc.view)
+ sv.delegate = context.coordinator
+ DispatchQueue.main.async {
+ resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) }
+ }
+ return sv
+ }
+
+ func updateUIView(_ scrollView: UIScrollView, context: Context) {
+ let hc = context.coordinator.hostingController
+ hc.rootView = content()
+ hc.view.frame.size = hc.view.intrinsicContentSize
+ scrollView.contentSize = hc.view.intrinsicContentSize
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(content: content())
+ }
+
+ class Coordinator: NSObject, UIScrollViewDelegate {
+ let hostingController: UIHostingController
+
+ init(content: Content) {
+ self.hostingController = UIHostingController(rootView: content)
+ }
+
+ func scrollViewWillEndDragging(
+ _ scrollView: UIScrollView,
+ withVelocity velocity: CGPoint,
+ targetContentOffset: UnsafeMutablePointer
+ ) {
+ if targetContentOffset.pointee.x < 32 {
+ targetContentOffset.pointee.x = 0
+ }
+ }
+ }
+}
+
+struct ResetScrollAction {
+ var action = { }
+ func callAsFunction() { action() }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift b/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
index ae94b4685c..9d5ae2e289 100644
--- a/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
+++ b/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
@@ -16,6 +16,7 @@ struct UserWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
+ @State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -125,24 +126,27 @@ struct UserWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
- ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
- let importedFromString = imported.wallpaper?.importFromString()
- let importedType = importedFromString?.toAppWallpaper().type
- let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
- let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
- let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
- let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
- Task {
- await MainActor.run {
- themeModeOverride = res
- }
- await save(applyToMode, res)
- }
- }
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { imported in
+ let importedFromString = imported.wallpaper?.importFromString()
+ let importedType = importedFromString?.toAppWallpaper().type
+ let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
+ let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
+ let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
+ let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
+ Task {
+ await MainActor.run {
+ themeModeOverride = res
+ }
+ await save(applyToMode, res)
+ }
+ }
+ )
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -216,6 +220,7 @@ struct ChatWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
+ @State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -328,24 +333,27 @@ struct ChatWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: editColor)
- ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
- let importedFromString = imported.wallpaper?.importFromString()
- let importedType = importedFromString?.toAppWallpaper().type
- let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
- let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
- let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
- let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
- Task {
- await MainActor.run {
- themeModeOverride = res
- }
- await save(applyToMode, res)
- }
- }
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { imported in
+ let importedFromString = imported.wallpaper?.importFromString()
+ let importedType = importedFromString?.toAppWallpaper().type
+ let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
+ let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
+ let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
+ Task {
+ await MainActor.run {
+ themeModeOverride = res
+ }
+ await save(applyToMode, res)
+ }
+ }
+ )
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
diff --git a/apps/ios/Shared/Views/Helpers/UserDefault.swift b/apps/ios/Shared/Views/Helpers/UserDefault.swift
new file mode 100644
index 0000000000..5f18465d20
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/UserDefault.swift
@@ -0,0 +1,62 @@
+//
+// UserDefault.swift
+// SimpleX (iOS)
+//
+// Created by user on 14/10/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import Combine
+
+@propertyWrapper
+public struct UserDefault: DynamicProperty {
+ @StateObject private var observer = UserDefaultObserver()
+ let initialValue: Value
+ let key: String
+ let store: UserDefaults
+
+ public init(
+ wrappedValue: Value,
+ _ key: String,
+ store: UserDefaults = .standard
+ ) {
+ self.initialValue = wrappedValue
+ self.key = key
+ self.store = store
+ }
+
+ public var wrappedValue: Value {
+ get {
+ // Observer can only be accessed after the property wrapper is installed in view (runtime exception)
+ observer.subscribe(to: key)
+ return store.object(forKey: key) as? Value ?? initialValue
+ }
+ nonmutating set {
+ store.set(newValue, forKey: key)
+ }
+ }
+}
+
+private class UserDefaultObserver: ObservableObject {
+ private var subscribed = false
+
+ func subscribe(to key: String) {
+ if !subscribed {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(userDefaultsDidChange),
+ name: UserDefaults.didChangeNotification,
+ object: nil
+ )
+ subscribed = true
+ }
+ }
+
+ @objc
+ private func userDefaultsDidChange(_ notification: Notification) {
+ Task { @MainActor in objectWillChange.send() }
+ }
+
+ deinit { NotificationCenter.default.removeObserver(self) }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
index c790b9cff2..85ef85c611 100644
--- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
+++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
@@ -9,6 +9,7 @@
import SwiftUI
extension View {
+ @inline(__always)
@ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
@@ -36,9 +37,9 @@ struct PrivacyBlur: ViewModifier {
.overlay {
if (blurred && enabled) {
Color.clear.contentShape(Rectangle())
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
blurred = false
- }
+ })
}
}
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
index 27bb95b599..16ab26eff7 100644
--- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
+++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
@@ -65,6 +65,7 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
index 609943bcb6..4a6f8e7549 100644
--- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
+++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
@@ -28,7 +28,7 @@ struct PasscodeEntry: View {
}
}
- @ViewBuilder private func passwordView() -> some View {
+ private func passwordView() -> some View {
Text(
password == ""
? " "
diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
index 9cc229ba80..0af8fa7ad8 100644
--- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
@@ -24,6 +24,7 @@ private enum MigrationFromState: Equatable {
}
private enum MigrateFromDeviceViewAlert: Identifiable {
+ case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
@@ -38,6 +39,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
var id: String {
switch self {
+ case .finishMigration: return "finishMigration"
case let .deleteChat(title, text): return "\(title) \(text)"
case let .startChat(title, text): return "\(title) \(text)"
@@ -56,8 +58,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
struct MigrateFromDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @Environment(\.dismiss) var dismiss: DismissAction
- @Binding var showSettings: Bool
@Binding var showProgressOnSettings: Bool
@State private var migrationState: MigrationFromState = .chatStopInProgress
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@@ -106,9 +106,6 @@ struct MigrateFromDevice: View {
finishedView(chatDeletion)
}
}
- .modifier(BackButton(label: "Back", disabled: $backDisabled) {
- dismiss()
- })
.onChange(of: migrationState) { state in
backDisabled = switch migrationState {
case .chatStopInProgress, .archiving, .linkShown, .finished: true
@@ -138,6 +135,15 @@ struct MigrateFromDevice: View {
}
.alert(item: $alert) { alert in
switch alert {
+ case let .finishMigration(fileId, ctrl):
+ return Alert(
+ title: Text("Remove archive?"),
+ message: Text("The uploaded database archive will be permanently removed from the servers."),
+ primaryButton: .destructive(Text("Continue")) {
+ finishMigration(fileId, ctrl)
+ },
+ secondaryButton: .cancel()
+ )
case let .startChat(title, text):
return Alert(
title: Text(title),
@@ -171,7 +177,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
+ message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) }
}
@@ -216,7 +222,8 @@ struct MigrateFromDevice: View {
}
private func passphraseNotSetView() -> some View {
- DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: true, stopChatRunBlockStartChat: { _, _ in
+ })
.onChange(of: initialRandomDBPassphrase) { initial in
if !initial {
migrationState = .uploadConfirmation
@@ -318,7 +325,7 @@ struct MigrateFromDevice: View {
Text("Cancel migration").foregroundColor(.red)
}
}
- Button(action: { finishMigration(fileId, ctrl) }) {
+ Button(action: { alert = .finishMigration(fileId, ctrl) }) {
settingsRow("checkmark", color: theme.colors.secondary) {
Text("Finalize migration").foregroundColor(theme.colors.primary)
}
@@ -513,19 +520,25 @@ struct MigrateFromDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run {
switch msg {
- case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize):
+ case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)):
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
}
- case .sndFileRedirectStartXFTP:
+ case .result(.sndFileRedirectStartXFTP):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkCreation
}
- case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
+ case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)):
let cfg = getNetCfg()
+ let proxy: NetworkProxy? = if cfg.socksProxy == nil {
+ nil
+ } else {
+ networkProxyDefault.get()
+ }
let data = MigrationFileLinkData.init(
networkConfig: MigrationFileLinkData.NetworkConfig(
socksProxy: cfg.socksProxy,
+ networkProxy: proxy,
hostMode: cfg.hostMode,
requiredHostMode: cfg.requiredHostMode
)
@@ -533,7 +546,7 @@ struct MigrateFromDevice: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl)
}
- case .sndFileError:
+ case .result(.sndFileError):
alert = .error(title: "Upload failed", error: "Check your internet connection and try again")
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
default:
@@ -590,7 +603,7 @@ struct MigrateFromDevice: View {
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
- showSettings = false
+ dismissAllSheets(animated: true)
}
} catch let error {
alert = .error(title: "Error deleting database", error: responseError(error))
@@ -613,9 +626,7 @@ struct MigrateFromDevice: View {
}
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if dismiss || m.chatDbStatus != .ok {
- await MainActor.run {
- showSettings = false
- }
+ dismissAllSheets(animated: true)
}
}
@@ -680,7 +691,7 @@ private struct PassphraseConfirmationView: View {
migrationState = .uploadConfirmation
}
} catch let error {
- if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
+ if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
@@ -722,11 +733,11 @@ func chatStoppedView() -> some View {
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let databaseUrl: URL
- let processReceivedMsg: (ChatResponse) async -> Void
+ let processReceivedMsg: (APIResult) async -> Void
private var receiveLoop: Task?
private var receiveMessages = true
- init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
+ init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) {
self.ctrl = ctrl
self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg
@@ -741,9 +752,9 @@ private class MigrationChatReceiver {
func receiveMsgLoop() async {
// TODO use function that has timeout
- if let msg = await chatRecvMsg(ctrl) {
+ if let msg: APIResult = await chatRecvMsg(ctrl) {
Task {
- await TerminalItems.shared.add(.resp(.now, msg))
+ await TerminalItems.shared.addResult(msg)
}
logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg)
@@ -767,6 +778,6 @@ private class MigrationChatReceiver {
struct MigrateFromDevice_Previews: PreviewProvider {
static var previews: some View {
- MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
+ MigrateFromDevice(showProgressOnSettings: Binding.constant(false))
}
}
diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
index 67ea1008cd..93fe19cf33 100644
--- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
@@ -93,15 +93,19 @@ struct MigrateToDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
- @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert?
+ @State private var databaseAlert: DatabaseAlert? = nil
private let tempDatabaseUrl = urlForTemporaryDatabase()
@State private var chatReceiver: MigrationChatReceiver? = nil
// Prevent from hiding the view until migration is finished or app deleted
@State private var backDisabled: Bool = false
@State private var showQRCodeScanner: Bool = true
+ @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
+
+ @State private var importingArchiveFromFileProgressIndicator = false
+ @State private var showFileImporter = false
var body: some View {
VStack {
@@ -175,6 +179,20 @@ struct MigrateToDevice: View {
return Alert(title: Text(title), message: Text(error))
}
}
+ .alert(item: $databaseAlert) { item in
+ switch item {
+ case .archiveImported:
+ let (title, message) = archiveImportedAlertText()
+ return Alert(title: Text(title), message: Text(message))
+ case let .archiveImportedWithErrors(errs):
+ let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
+ return Alert(title: Text(title), message: Text(message))
+ case let .error(title, error):
+ return Alert(title: Text(title), message: Text(error))
+ default: // not expected this branch to be called because this alert is used only for importArchive purpose
+ return Alert(title: Text("Error"))
+ }
+ }
.interactiveDismissDisabled(backDisabled)
}
@@ -197,11 +215,15 @@ struct MigrateToDevice: View {
}
}
}
- if developerTools {
- Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
- pasteLinkView()
- }
+ Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
+ pasteLinkView()
}
+ Section(header: Text("Or import archive file").foregroundColor(theme.colors.secondary)) {
+ archiveImportFromFileView()
+ }
+ }
+ if importingArchiveFromFileProgressIndicator {
+ progressView()
}
}
}
@@ -218,10 +240,38 @@ struct MigrateToDevice: View {
} label: {
Text("Tap to paste link")
}
- .disabled(!ChatModel.shared.pasteboardHasStrings)
+ .disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
}
+ private func archiveImportFromFileView() -> some View {
+ Button {
+ showFileImporter = true
+ } label: {
+ Label("Import database", systemImage: "square.and.arrow.down")
+ }
+ .disabled(importingArchiveFromFileProgressIndicator)
+ .fileImporter(
+ isPresented: $showFileImporter,
+ allowedContentTypes: [.zip],
+ allowsMultipleSelection: false
+ ) { result in
+ if case let .success(files) = result, let fileURL = files.first {
+ Task {
+ let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true)
+ if success {
+ DatabaseView.startChat(
+ Binding.constant(false),
+ $importingArchiveFromFileProgressIndicator
+ )
+ hideView()
+ }
+ }
+ }
+ }
+ }
+
+
private func linkDownloadingView(_ link: String) -> some View {
ZStack {
List {
@@ -446,10 +496,10 @@ struct MigrateToDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run {
switch msg {
- case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
+ case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)):
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
- case .rcvStandaloneFileComplete:
+ case .result(.rcvStandaloneFileComplete):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// User closed the whole screen before new state was saved
if migrationState == nil {
@@ -459,10 +509,10 @@ struct MigrateToDevice: View {
MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
}
}
- case .rcvFileError:
+ case .result(.rcvFileError):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
- case .chatError(_, .error(.noRcvFileUser)):
+ case .error(.error(.noRcvFileUser)):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
default:
@@ -487,6 +537,9 @@ struct MigrateToDevice: View {
do {
if !hasChatCtrl() {
chatInitControllerRemovingDatabases()
+ } else if ChatModel.shared.chatRunning == true {
+ // cannot delete storage if chat is running
+ try await stopChatAsync()
}
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@@ -556,11 +609,22 @@ struct MigrateToDevice: View {
do {
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
MigrationToDeviceState.save(nil)
- appSettings.importIntoApp()
- try SimpleX.startChat(refreshInvitations: true)
- AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
+ try ObjC.catchException {
+ appSettings.importIntoApp()
+ }
+ do {
+ try SimpleX.startChat(refreshInvitations: true)
+ AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
+ } catch let error {
+ AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
+ }
} catch let error {
- AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
+ logger.error("Error importing settings: \(error.localizedDescription)")
+ AlertManager.shared.showAlert(
+ Alert(
+ title: Text("Error migrating settings"),
+ message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error)))
+ )
}
hideView()
}
@@ -568,6 +632,8 @@ struct MigrateToDevice: View {
private func hideView() {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
+ m.migrationState = nil
+ MigrationToDeviceState.save(nil)
dismiss()
}
@@ -685,11 +751,11 @@ private func progressView() -> some View {
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let databaseUrl: URL
- let processReceivedMsg: (ChatResponse) async -> Void
+ let processReceivedMsg: (APIResult) async -> Void
private var receiveLoop: Task?
private var receiveMessages = true
- init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
+ init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult