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 a5a07a8722..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,155 +94,295 @@ 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-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 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
+ 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: 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'
- 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'
+ if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@@ -200,88 +392,77 @@ jobs:
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'
- 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'
- 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
- # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
+ 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
+ - 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
@@ -293,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
@@ -311,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/cla.yml b/.github/workflows/cla.yml
index 72a7cf2b94..b396c9a289 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -5,14 +5,20 @@ on:
pull_request_target:
types: [opened, closed, synchronize]
+permissions:
+ actions: write
+ contents: write
+ pull-requests: write
+ statuses: write
+
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
- if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
+ if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
- uses: cla-assistant/github-action@v2.1.3-beta
+ uses: cla-assistant/github-action@v2.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
@@ -33,4 +39,4 @@ jobs:
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
- #use-dco-flag: true - If you are using DCO instead of CLA
\ No newline at end of file
+ #use-dco-flag: true - If you are using DCO instead of CLA
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 039c136464..5fbe8293bc 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -4,13 +4,13 @@ on:
push:
branches:
- master
- - stable
paths:
- website/**
- images/**
- blog/**
- docs/**
- .github/workflows/web.yml
+ - PRIVACY.md
jobs:
build:
@@ -33,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 d7106ec8fa..645b55ec9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,12 +54,14 @@ website/translations.json
website/src/img/images/
website/src/images/
website/src/js/lottie.min.js
+website/src/privacy.md
# Generated files
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 3204fa1e53..18e5539726 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,60 +1,95 @@
-# SimpleX Chat Privacy Policy and Conditions of Use
+---
+layout: layouts/privacy.html
+permalink: /privacy/index.html
+---
-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.
+# SimpleX Chat Operators Privacy Policy and Conditions of Use
-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.
+## Summary
-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)).
+[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.
-If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](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](#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 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
@@ -70,99 +105,142 @@ 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.
-You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
+You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off).
#### 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
-[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).
+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.
+
+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](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 and acceptable usage**. You agree to use our Applications only for legal and acceptable 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 or impermissible communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal or impermissible 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 February 24, 2024
+Updated March 3, 2025
diff --git a/README.md b/README.md
index 28b40d0c1b..554c6068d9 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,13 @@
[](https://www.reddit.com/r/SimpleXChat)

-| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
+| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
# 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!
@@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
-5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
+5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
[Learn more about SimpleX Chat](#contents).
@@ -72,9 +72,9 @@ 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-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
+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:
- chat bots and automations
- integrations with other apps
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&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,16 +150,7 @@ 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.
-
-## Help us with donations
+## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -158,20 +158,21 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
-Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
+Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
-- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
-- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
-- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
-- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
-- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
-- USDT:
- - BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- - Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
-- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
-- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
+- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
+- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
+- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
+- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
+- 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,
@@ -234,28 +235,28 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
-[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 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)
[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
@@ -295,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
@@ -380,10 +384,12 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Private notes.
- ✅ 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.
-- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- 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.
@@ -402,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.
@@ -412,13 +420,13 @@ We have never provided or have been requested access to our servers or any infor
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
-Please read more in [Terms & privacy policy](./PRIVACY.md).
+Please read more in [Privacy Policy](./PRIVACY.md).
## Security contact
-To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
+Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
-Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
+Please do NOT report security vulnerabilities via GitHub issues.
## License
diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift
index 7204625ad4..3f6998c9ec 100644
--- a/apps/ios/Shared/AppDelegate.swift
+++ b/apps/ios/Shared/AppDelegate.swift
@@ -9,39 +9,18 @@
import Foundation
import UIKit
import SimpleXChat
+import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
- if #available(iOS 17.0, *) { trackKeyboard() }
- NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
+ prepareForLaunch()
+ deleteOldChatArchive()
return true
}
- @available(iOS 17.0, *)
- private func trackKeyboard() {
- NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
- NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
- }
-
- @available(iOS 17.0, *)
- @objc func keyboardWillShow(_ notification: Notification) {
- if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
- ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
- }
- }
-
- @available(iOS 17.0, *)
- @objc func keyboardWillHide(_ notification: Notification) {
- ChatModel.shared.keyboardHeight = 0
- }
-
- @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)")
@@ -75,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))")
@@ -141,6 +120,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
}
+ private func prepareForLaunch() {
+ try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
+ }
+
static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on
}
@@ -148,13 +131,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow?
+ static var windowStatic: UIWindow?
var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+ UITableView.appearance().backgroundColor = .clear
guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene
window = windowScene.keyWindow
- window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
- window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
+ SceneDelegate.windowStatic = windowScene.keyWindow
+ migrateAccentColorAndTheme()
+ ThemeManager.applyTheme(currentThemeDefault.get())
+ ThemeManager.adjustWindowStyle()
+ }
+
+ private func migrateAccentColorAndTheme() {
+ let defs = UserDefaults.standard
+ /// For checking migration
+// themeOverridesDefault.set([])
+// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
+// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
+// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
+// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
+
+ let userInterfaceStyle = getUserInterfaceStyleDefault()
+ if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
+ // No migration needed or already migrated
+ return
+ }
+
+ let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
+ let accentColor = Color(cgColor: getUIAccentColorDefault())
+ if accentColor != defaultAccentColor {
+ let colors = ThemeColors(primary: accentColor.toReadableHex())
+ var overrides = themeOverridesDefault.get()
+ var themeIds = currentThemeIdsDefault.get()
+ switch userInterfaceStyle {
+ case .light:
+ let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
+ overrides.append(light)
+ themeOverridesDefault.set(overrides)
+ themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
+ currentThemeIdsDefault.set(themeIds)
+ ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
+ case .dark:
+ let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
+ overrides.append(dark)
+ themeOverridesDefault.set(overrides)
+ themeIds[DefaultTheme.DARK.themeName] = dark.themeId
+ currentThemeIdsDefault.set(themeIds)
+ ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
+ case .unspecified:
+ let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
+ let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
+ overrides.append(light)
+ overrides.append(dark)
+ themeOverridesDefault.set(overrides)
+ themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
+ themeIds[DefaultTheme.DARK.themeName] = dark.themeId
+ currentThemeIdsDefault.set(themeIds)
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
+ @unknown default: ()
+ }
+ } else if userInterfaceStyle != .unspecified {
+ let themeName = switch userInterfaceStyle {
+ case .light: DefaultTheme.LIGHT.themeName
+ case .dark: DefaultTheme.DARK.themeName
+ default: DefaultTheme.SYSTEM_THEME_NAME
+ }
+ ThemeManager.applyTheme(themeName)
+ }
+ defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
+ defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
+ defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
+ defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
}
}
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/Assets.xcassets/wallpaper_cats.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/Contents.json
new file mode 100644
index 0000000000..a1747ab6ba
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_cats@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_cats@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_cats@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@1x.png
new file mode 100644
index 0000000000..7d4624c3f9
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@2x.png
new file mode 100644
index 0000000000..1015139393
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@3x.png
new file mode 100644
index 0000000000..9bff3eb3d0
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_cats.imageset/wallpaper_cats@3x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/Contents.json
new file mode 100644
index 0000000000..c6bc439be2
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_flowers@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_flowers@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_flowers@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@1x.png
new file mode 100644
index 0000000000..965f552599
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@2x.png
new file mode 100644
index 0000000000..0cb219acd3
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@3x.png
new file mode 100644
index 0000000000..59246eb50d
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_flowers.imageset/wallpaper_flowers@3x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/Contents.json
new file mode 100644
index 0000000000..556d01a6f2
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_hearts@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_hearts@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_hearts@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@1x.png
new file mode 100644
index 0000000000..780ff13513
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@2x.png
new file mode 100644
index 0000000000..cee89e57d9
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@3x.png
new file mode 100644
index 0000000000..35da7c7aed
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_hearts.imageset/wallpaper_hearts@3x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/Contents.json
new file mode 100644
index 0000000000..aba5903ec0
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_kids@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_kids@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_kids@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@1x.png
new file mode 100644
index 0000000000..83e48b4f78
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@2x.png
new file mode 100644
index 0000000000..1927c2fe2a
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@3x.png
new file mode 100644
index 0000000000..f5f15d3643
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_kids.imageset/wallpaper_kids@3x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/Contents.json
new file mode 100644
index 0000000000..59c209b134
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_school@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_school@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_school@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@1x.png
new file mode 100644
index 0000000000..c95ac60b6e
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@2x.png
new file mode 100644
index 0000000000..81a3a3d94d
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@3x.png
new file mode 100644
index 0000000000..f6e1cce383
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_school.imageset/wallpaper_school@3x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/Contents.json
new file mode 100644
index 0000000000..4e56988263
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "wallpaper_travel@1x.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "wallpaper_travel@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "wallpaper_travel@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@1x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@1x.png
new file mode 100644
index 0000000000..c1d825b86e
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@1x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@2x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@2x.png
new file mode 100644
index 0000000000..d640f10c7c
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@2x.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@3x.png b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@3x.png
new file mode 100644
index 0000000000..64ec137331
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wallpaper_travel.imageset/wallpaper_travel@3x.png differ
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index acea38e69e..2ad8d546f2 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -9,11 +9,24 @@ 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
var contentAccessAuthenticationExtended: Bool
@@ -27,14 +40,15 @@ 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 = 50
+ private let callTopPadding: CGFloat = 40
private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -51,6 +65,16 @@ struct ContentView: View {
}
var body: some View {
+ if #available(iOS 16.0, *) {
+ allViews()
+ .scrollContentBackground(.hidden)
+ } else {
+ // on iOS 15 scroll view background disabled in SceneDelegate
+ allViews()
+ }
+ }
+
+ 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.
@@ -74,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
@@ -97,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 }
@@ -138,6 +159,17 @@ struct ContentView: View {
break
}
}
+ .onAppear {
+ reactOnDarkThemeChanges(systemInDarkThemeCurrently)
+ }
+ .onChange(of: colorScheme) { scheme in
+ // It's needed to update UI colors when iOS wants to make screenshot after going to background,
+ // so when a user changes his global theme from dark to light or back, the app will adapt to it
+ reactOnDarkThemeChanges(scheme == .dark)
+ }
+ .onChange(of: theme.name) { _ in
+ ThemeManager.adjustWindowStyle()
+ }
}
@ViewBuilder private func contentView() -> some View {
@@ -177,14 +209,14 @@ 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()
CallDuration(call: call)
}
.padding(.horizontal)
- .frame(height: callTopPadding - 10)
+ .frame(height: callTopPadding)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
@@ -224,13 +256,14 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
- .fill(.background)
- )
+ .fill(theme.colors.background)
+ )
}
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
@@ -239,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()
@@ -259,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) {
@@ -277,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)
+ }
}
}
}
@@ -372,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(
@@ -394,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/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift
index a9d0d6c1d9..99851f4be8 100644
--- a/apps/ios/Shared/Model/AudioRecPlay.swift
+++ b/apps/ios/Shared/Model/AudioRecPlay.swift
@@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
if playback {
if AVAudioSession.sharedInstance().category != .playback {
logger.log("AudioSession: playback")
- try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
+ try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: [.duckOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
} else {
if AVAudioSession.sharedInstance().category != .soloAmbient {
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 462699e407..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)
}
}
@@ -43,11 +52,224 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
+class ItemsModel: ObservableObject {
+ static let shared = ItemsModel()
+ private let publisher = ObservableObjectPublisher()
+ private var bag = Set()
+ var reversedChatItems: [ChatItem] = [] {
+ willSet { publisher.send() }
+ }
+ 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: 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.2, scheduler: DispatchQueue.main, latest: true)
+ .sink { self.objectWillChange.send() }
+ .store(in: &bag)
+ }
+
+ func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
+ navigationTimeoutTask?.cancel()
+ loadChatTask?.cancel()
+ navigationTimeoutTask = Task {
+ do {
+ try await Task.sleep(nanoseconds: 250_000000)
+ await MainActor.run {
+ ChatModel.shared.chatId = chatId
+ willNavigate()
+ }
+ } catch {}
+ }
+ 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 = 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 {
+ // map of connections network statuses, key is agent connection id
+ @Published var networkStatuses: Dictionary = [:]
+
+ static let shared = NetworkModel()
+
+ private init() { }
+
+ func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
+ if let conn = contact.activeConn {
+ networkStatuses[conn.agentConnId] = status
+ }
+ }
+
+ func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
+ if let conn = contact.activeConn {
+ networkStatuses[conn.agentConnId] ?? .unknown
+ } else {
+ .unknown
+ }
+ }
+}
+
+/// 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
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
- @Published var currentUser: User?
+ @Published var currentUser: User? {
+ didSet {
+ ThemeManager.applyTheme(currentThemeDefault.get())
+ }
+ }
@Published var users: [UserInfo] = []
@Published var chatInitialized = false
@Published var chatRunning: Bool?
@@ -55,20 +277,21 @@ 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?
// list of chat "previews"
- @Published var chats: [Chat] = []
+ @Published private(set) var chats: [Chat] = []
@Published var deletedChats: Set = []
- // map of connections network statuses, key is agent connection id
- @Published var networkStatuses: Dictionary = [:]
// current chat
@Published var chatId: String?
- @Published var reversedChatItems: [ChatItem] = []
+ @Published var openAroundItemId: ChatItem.ID? = nil
var chatItemStatuses: Dictionary = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
+ @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list
+ @Published var membersLoaded = false
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -78,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?
@@ -100,9 +324,9 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
@Published var draftChatId: String?
- // tracks keyboard height via subscription in AppDelegate
- @Published var keyboardHeight: CGFloat = 0
- @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> = [:]
@@ -110,6 +334,8 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel()
+ let im = ItemsModel.shared
+
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -175,18 +401,47 @@ final class ChatModel: ObservableObject {
}
}
+ func populateGroupMembersIndexes() {
+ groupMembersIndexes.removeAll()
+ for (i, member) in groupMembers.enumerated() {
+ groupMembersIndexes[member.groupMemberId] = i
+ }
+ }
+
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
- groupMembers.first { $0.groupMemberId == groupMemberId }
+ if let i = groupMembersIndexes[groupMemberId] {
+ return groupMembers[i]
+ }
+ return nil
+ }
+
+ func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
+ let groupMembers = await apiListMembers(groupInfo.groupId)
+ await MainActor.run {
+ if chatId == groupInfo.id {
+ self.groupMembers = groupMembers.map { GMember.init($0) }
+ self.populateGroupMembersIndexes()
+ self.membersLoaded = true
+ updateView()
+ }
+ }
}
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
- func addChat(_ chat: Chat, at position: Int = 0) {
- withAnimation {
- chats.insert(chat, at: position)
+ func addChat(_ chat: Chat) {
+ if chatId == nil {
+ withAnimation { addChat_(chat, at: 0) }
+ } else {
+ addChat_(chat, at: 0)
}
+ popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
+ }
+
+ func addChat_(_ chat: Chat, at position: Int = 0) {
+ chats.insert(chat, at: position)
}
func updateChatInfo(_ cInfo: ChatInfo) {
@@ -221,6 +476,7 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
+ ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
}
}
@@ -244,26 +500,10 @@ final class ChatModel: ObservableObject {
}
}
- func updateChats(with newChats: [ChatData]) {
- for i in 0.. 0 {
- if chatId == nil {
- withAnimation { popChat_(i) }
- } else if chatId == cInfo.id {
- chatToTop = cInfo.id
- } else {
- popChat_(i)
- }
+ unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
+ popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
}
@@ -314,7 +551,7 @@ final class ChatModel: ObservableObject {
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
- if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
+ if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
@@ -325,24 +562,27 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
+ if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
+ VoiceItemState.stopVoiceInChatView(cInfo, cItem)
+ }
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
- withAnimation {
- _updateChatItem(at: i, with: cItem)
- }
+ _updateChatItem(at: i, with: cItem)
+ ChatItemDummyModel.shared.sendUpdate()
return false
} else {
- withAnimation(itemAnimation()) {
- var ci = cItem
- if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
- ci.meta.itemStatus = status
- }
- reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
+ 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
}
@@ -356,7 +596,7 @@ final class ChatModel: ObservableObject {
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
- withAnimation {
+ withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
}
} else if let status = status {
@@ -365,17 +605,17 @@ final class ChatModel: ObservableObject {
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
- reversedChatItems[i] = cItem
- reversedChatItems[i].viewTimestamp = .now
+ im.reversedChatItems[i] = cItem
+ im.reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
- reversedChatItems.firstIndex(where: { $0.id == cItem.id })
+ im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
- decreaseUnreadCounter(cInfo)
+ unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
}
// update previews
if let chat = getChat(cInfo.id) {
@@ -386,24 +626,65 @@ final class ChatModel: ObservableObject {
// remove from current chat
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
- _ = withAnimation {
- self.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 = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
+ guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
- while i < reversedChatItems.count - 1 {
+ while i < im.reversedChatItems.count - 1 {
i += 1
- if let res = map(reversedChatItems[i]) { return res }
+ if let res = map(im.reversedChatItems[i]) { return res }
}
} else {
while i > 0 {
i -= 1
- if let res = map(reversedChatItems[i]) { return res }
+ if let res = map(im.reversedChatItems[i]) { return res }
}
}
return nil
@@ -421,10 +702,22 @@ final class ChatModel: ObservableObject {
}
}
+ func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
+ 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 {
+ users[i].user = current
+ }
+ currentUser = current
+ }
+
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
- reversedChatItems.insert(cItem, at: 0)
+ im.reversedChatItems.insert(cItem, at: 0)
+ im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
+ im.itemAdded = true
}
return cItem
}
@@ -432,130 +725,247 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy {
if animated {
- withAnimation { _ = reversedChatItems.removeFirst() }
+ withAnimation { _ = im.reversedChatItems.removeFirst() }
} else {
- _ = reversedChatItems.removeFirst()
+ _ = im.reversedChatItems.removeFirst()
}
}
}
private var hasLiveDummy: Bool {
- reversedChatItems.first?.isLiveDummy == true
+ 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 < 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.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
}
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
- reversedChatItems = []
+ im.reversedChatItems = []
+ im.chatState.clear()
}
}
- func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
- // update preview
- decreaseUnreadCounter(cInfo)
- // update current chat
- if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
- markChatItemRead_(i)
+ 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()
+
+ class UnreadCollector {
+ private let subject = PassthroughSubject()
+ private var bag = Set()
+ private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
+
+ init() {
+ subject
+ .debounce(for: 1, scheduler: DispatchQueue.main)
+ .sink {
+ let m = ChatModel.shared
+ 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 = [:]
+ }
+ .store(in: &bag)
+ }
+
+ 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()
+ }
+ }
+
+ let popChatCollector = PopChatCollector()
+
+ class PopChatCollector {
+ private let subject = PassthroughSubject()
+ private var bag = Set()
+ private var chatsToPop: [ChatId: Date] = [:]
+ private let popTsComparator = KeyPathComparator(\.popTs, order: .reverse)
+
+ init() {
+ subject
+ .throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
+ .sink { self.popCollectedChats() }
+ .store(in: &bag)
+ }
+
+ func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
+ let m = ChatModel.shared
+ if currentPosition > 0 && m.chatId == chatId {
+ m.chatToTop = chatId
+ }
+ if currentPosition > 0 || !chatsToPop.isEmpty {
+ chatsToPop[chatId] = Date.now
+ subject.send()
+ }
+ }
+
+ func clear() {
+ chatsToPop = [:]
+ }
+
+ func popCollectedChats() {
+ let m = ChatModel.shared
+ var ixs: IndexSet = []
+ var chs: [Chat] = []
+ // collect chats that received updates
+ for (chatId, popTs) in self.chatsToPop {
+ // Currently opened chat is excluded, removing it from the list would navigate out of it
+ // It will be popped to top later when user exits from the list.
+ if m.chatId != chatId, let i = m.getChatIndex(chatId) {
+ ixs.insert(i)
+ let ch = m.chats[i]
+ ch.popTs = popTs
+ chs.append(ch)
+ }
+ }
+
+ let removeInsert = {
+ m.chats.remove(atOffsets: ixs)
+ // sort chats by pop timestamp in descending order
+ m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
+ }
+
+ if m.chatId == nil {
+ withAnimation { removeInsert() }
+ } else {
+ removeInsert()
+ }
+
+ self.chatsToPop = [:]
}
}
private func markChatItemRead_(_ i: Int) {
- let meta = reversedChatItems[i].meta
+ let meta = im.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
- reversedChatItems[i].meta.itemStatus = .rcvRead
- reversedChatItems[i].viewTimestamp = .now
+ im.reversedChatItems[i].meta.itemStatus = .rcvRead
+ im.reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
- reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
+ im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
- func decreaseUnreadCounter(_ cInfo: ChatInfo) {
- if let i = getChatIndex(cInfo.id) {
- chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
- decreaseUnreadCounter(user: currentUser!)
- }
+ 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)
}
func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1)
- NtfManager.shared.incNtfBadgeCount()
+ }
+
+ 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)
- NtfManager.shared.decNtfBadgeCount(by: by)
}
private func changeUnreadCounter(user: any UserLike, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by
}
+ NtfManager.shared.changeNtfBadgeCount(by: by)
}
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
@@ -564,8 +974,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
- while i < reversedChatItems.count {
- let ci = reversedChatItems[i]
+ while i < im.reversedChatItems.count {
+ let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
@@ -580,7 +990,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
- (i, i > 0 ? reversedChatItems[i - 1] : nil)
+ (i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
}
@@ -590,10 +1000,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
- let fst = reversedChatItems.count - 1
+ let fst = im.reversedChatItems.count - 1
while i < fst {
i = i + 1
- let ci = reversedChatItems[i]
+ let ci = im.reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
@@ -603,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) = 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)
@@ -616,6 +1031,7 @@ final class ChatModel: ObservableObject {
func popChat(_ id: String) {
if let i = getChatIndex(id) {
+ // no animation here, for it not to look like it just moved when leaving the chat
popChat_(i)
}
}
@@ -626,7 +1042,7 @@ final class ChatModel: ObservableObject {
}
func dismissConnReqView(_ id: String) {
- if id == showingInvitation?.connId {
+ if id == showingInvitation?.pcc.id {
markShowingInvitationUsed()
dismissAllSheets()
}
@@ -638,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)
+ }
}
}
@@ -650,14 +1070,17 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
- if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
+ if let i = groupMembersIndexes[member.groupMemberId] {
withAnimation(.default) {
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
- withAnimation { groupMembers.append(GMember(member)) }
+ withAnimation {
+ groupMembers.append(GMember(member))
+ groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
+ }
return true
}
} else {
@@ -674,46 +1097,26 @@ final class ChatModel: ObservableObject {
}
}
- func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts {
- var i = 0
- var totalBelow = 0
- var unreadBelow = 0
- while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
- totalBelow += 1
- if reversedChatItems[i].isRcvNew {
- unreadBelow += 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)
+ }
+ }
+
+ 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)
}
- i += 1
- }
- return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
- }
-
- func topItemInView(itemsInView: Set) -> ChatItem? {
- let maxIx = reversedChatItems.count - 1
- var i = 0
- let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
- while i < maxIx && !inView(i) { i += 1 }
- while i < maxIx && inView(i) { i += 1 }
- return reversedChatItems[min(i - 1, maxIx)]
- }
-
- func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
- if let conn = contact.activeConn {
- networkStatuses[conn.agentConnId] = status
- }
- }
-
- func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
- if let conn = contact.activeConn {
- networkStatuses[conn.agentConnId] ?? .unknown
- } else {
- .unknown
}
}
}
struct ShowingInvitation {
- var connId: String
+ var pcc: PendingContactConnection
var connChatUsed: Bool
}
@@ -722,16 +1125,12 @@ struct NTFContactRequest {
var chatId: String
}
-struct UnreadChatItemCounts {
- var totalBelow: Int
- var unreadBelow: Int
-}
-
-final class Chat: ObservableObject, Identifiable {
+final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
var created = Date.now
+ fileprivate var popTs: Date?
init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo
@@ -753,27 +1152,14 @@ final class Chat: ObservableObject, Identifiable {
)
}
- 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/NetworkObserver.swift b/apps/ios/Shared/Model/NetworkObserver.swift
new file mode 100644
index 0000000000..84c35afa07
--- /dev/null
+++ b/apps/ios/Shared/Model/NetworkObserver.swift
@@ -0,0 +1,73 @@
+//
+// NetworkObserver.swift
+// SimpleX (iOS)
+//
+// Created by Avently on 05.04.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import Network
+import SimpleXChat
+
+class NetworkObserver {
+ static let shared = NetworkObserver()
+ private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver")
+ private var prevInfo: UserNetworkInfo? = nil
+ private var monitor: NWPathMonitor?
+ private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock")
+
+ func restartMonitor() {
+ monitorLock.sync {
+ monitor?.cancel()
+ let mon = NWPathMonitor()
+ mon.pathUpdateHandler = { [weak self] path in
+ self?.networkPathChanged(path: path)
+ }
+ mon.start(queue: queue)
+ monitor = mon
+ }
+ }
+
+ private func networkPathChanged(path: NWPath) {
+ let info = UserNetworkInfo(
+ networkType: networkTypeFromPath(path),
+ online: path.status == .satisfied
+ )
+ if (prevInfo != info) {
+ prevInfo = info
+ setNetworkInfo(info)
+ }
+ }
+
+ private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType {
+ if path.usesInterfaceType(.wiredEthernet) {
+ .ethernet
+ } else if path.usesInterfaceType(.wifi) {
+ .wifi
+ } else if path.usesInterfaceType(.cellular) {
+ .cellular
+ } else if path.usesInterfaceType(.other) {
+ .other
+ } else {
+ .none
+ }
+ }
+
+ private static var networkObserver: NetworkObserver? = nil
+
+ private func setNetworkInfo(_ info: UserNetworkInfo) {
+ logger.debug("setNetworkInfo Network changed: \(String(describing: info))")
+ DispatchQueue.main.sync {
+ ChatModel.shared.networkInfo = info
+ }
+ if !hasChatCtrl() { return }
+ self.monitorLock.sync {
+ do {
+ try apiSetNetworkInfo(info)
+ } catch let err {
+ logger.error("setNetworkInfo error: \(responseError(err))")
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index f1fdcc018e..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),
@@ -57,9 +74,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
}
} else {
- chatModel.chatId = content.targetContentIdentifier
+ if let chatId = content.targetContentIdentifier {
+ self.navigatingToChat = true
+ ItemsModel.shared.loadOpenChat(chatId) {
+ self.navigatingToChat = false
+ }
+ }
}
- handler()
}
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -74,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,
@@ -183,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")
)
])
}
@@ -208,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) {
@@ -238,12 +263,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count)
}
- func decNtfBadgeCount(by count: Int = 1) {
- setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
- }
-
- func incNtfBadgeCount(by count: Int = 1) {
- setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
+ func changeNtfBadgeCount(by count: Int = 1) {
+ setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
}
private func addNotification(_ content: UNMutableNotificationContent) {
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index a099069f77..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,51 +88,77 @@ 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) -> ChatResponse {
- logger.debug("chatSendCmd \(cmd.cmdType)")
+@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)
- logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
- if case let .response(_, json) = resp {
- logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
- }
- Task {
- await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
+ if log {
+ logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
+ if case let .invalid(_, json) = resp {
+ logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))")
+ }
+ Task {
+ await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
+ }
}
return resp
}
-func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) 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))
+ 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?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
- let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
+func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
+ 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] {
@@ -147,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 {
@@ -201,97 +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), 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: ChatResponse0 = try chatSendCmdSync(.checkChatRunning)
+ switch r {
+ case .chatRunning: return true
+ case .chatStopped: return false
+ 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 apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
+func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
-}
-
-func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), 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 apiSetPQEncryption(_ enable: Bool) throws {
- let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable))
- if case .cmdOk = r { return }
- throw r
-}
-
-func apiSetContactPQ(_ contactId: Int64, _ enable: Bool) async throws -> Contact {
- let r = await chatSendCmd(.apiSetContactPQ(contactId: contactId, enable: enable))
- if case let .contactPQAllowed(_, contact, _) = r { return contact }
- throw r
-}
-
-func apiExportArchive(config: ArchiveConfig) async throws {
- try await sendCommandOkResp(.apiExportArchive(config: config))
+func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
+ let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config))
+ if case let .archiveExported(archiveErrors) = r { return archiveErrors }
+ 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 {
@@ -302,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] {
@@ -316,48 +323,104 @@ 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 apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
- let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
- if case let .apiChat(_, chat) = r { return Chat.init(chat) }
- throw r
+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 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 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
}
-func loadChat(chat: Chat, search: String = "") {
- do {
- let cInfo = chat.chatInfo
- let m = ChatModel.shared
+let loadItemsPerPage = 50
+
+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 {
+ 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 = [:]
- m.reversedChatItems = []
- let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
- m.updateChatInfo(chat.chatInfo)
- m.reversedChatItems = chat.chatItems.reversed()
- } catch let error {
- logger.error("loadChat error: \(responseError(error))")
+ if clearItems {
+ im.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
+ }
}
+ 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 apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
+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 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]? {
let chatModel = ChatModel.shared
- let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
- let r: ChatResponse
- if type == .direct {
+ let r: APIResult
+ if toChatType == .direct {
var cItem: ChatItem? = nil
let endTask = beginBGTask({
if let cItem = cItem {
@@ -366,81 +429,118 @@ func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId:
}
}
})
- 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) {
- logger.error("apiSendMessage error: \(String(describing: r))")
+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",
- message: "Error: \(String(describing: r))"
+ message: "Error: \(responseError(r))"
)
}
-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: \(String(describing: r))"
+ 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 apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
- let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
- if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
- throw r
+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 apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
- let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
- if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
- throw r
+func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
+ 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.unexpected
+}
+
+func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
+ if case let .chatItemsDeleted(_, items, _) = r { return items }
+ 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)
@@ -448,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) {
@@ -462,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))")
}
@@ -470,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 {
@@ -512,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 {
@@ -522,22 +726,38 @@ 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: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
+ if case .cmdOk = r { return }
+ throw r.unexpected
}
func reconnectAllServers() async throws {
try await sendCommandOkResp(.reconnectAllServers)
}
+func reconnectServer(smpServer: String) async throws {
+ let userId = try currentUserId("reconnectServer")
+ try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
+}
+
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
@@ -547,106 +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 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 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.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
@@ -655,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) {
- await MainActor.run { m.chatId = c.id }
+ 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 {
@@ -706,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: \(String(describing: r))"
+ message: "Error: \(responseError(r.unexpected))"
)
}
}
@@ -722,29 +997,45 @@ 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)
}
-func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
+func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
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, notify: notify), 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 deleteChat(_ chat: Chat, notify: Bool? = nil) async {
+func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
+ let type: ChatType = .direct
+ let chatId = type.rawValue + id.description
+ if case .full = chatDeleteMode {
+ DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
+ }
+ defer {
+ if case .full = chatDeleteMode {
+ DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
+ }
+ }
+ 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.unexpected
+}
+
+func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
- try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
- DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
+ try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
+ await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
@@ -754,10 +1045,43 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
}
}
+func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
+ do {
+ let cInfo = chat.chatInfo
+ let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
+ await MainActor.run {
+ switch chatDeleteMode {
+ case .full:
+ ChatModel.shared.removeChat(cInfo.id)
+ case .entity:
+ ChatModel.shared.removeChat(cInfo.id)
+ ChatModel.shared.addChat(Chat(
+ chatInfo: .direct(contact: ct),
+ chatItems: chat.chatItems
+ ))
+ case .messages:
+ ChatModel.shared.removeChat(cInfo.id)
+ ChatModel.shared.addChat(Chat(
+ chatInfo: .direct(contact: ct),
+ chatItems: []
+ ))
+ }
+ }
+ } catch let error {
+ logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
+ return mkAlert(
+ title: "Error deleting chat!",
+ message: "Error: \(responseError(error))"
+ )
+ }
+ return nil
+}
+
+
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 {
@@ -772,98 +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 apiCreateUserAddress() async throws -> String {
+func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
+ 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 {
+ do {
+ try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes))
+ return true
+ } catch {
+ logger.error("apiSetChatUIThemes bad response: \(responseError(error))")
+ return false
+ }
+}
+
+
+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."
@@ -874,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: \(String(describing: 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 {
@@ -895,73 +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, String(describing: 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, String(describing: 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, auto: Bool = false) async {
- if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
- await chatItemSimpleUpdate(user, chatItem)
- }
+func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
+ await receiveFiles(
+ user: user,
+ fileIds: [fileId],
+ userApprovedRelays: userApprovedRelays,
+ auto: auto
+ )
}
-func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
- let r = await chatSendCmd(.receiveFile(fileId: fileId, 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
)
- }
- } else if let networkErrorAlert = networkErrorAlert(r) {
- logger.error("apiReceiveFile network error: \(String(describing: r))")
- am.showAlert(networkErrorAlert)
- } else {
- switch chatError(r) {
- case .fileCancelled:
- logger.debug("apiReceiveFile ignoring fileCancelled error")
- case .fileAlreadyReceiving:
- logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
+ )
+ 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:
- logger.error("apiReceiveFile error: \(String(describing: r))")
- am.showAlertMsg(
- title: "Error receiving file",
- message: "Error: \(String(describing: r))"
- )
+ otherFileErrs.append(r)
}
}
- return nil
+
+ 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)
+ }
+ }
+ .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)
+ }
+ }
+ },
+ cancelButton: true
+ )
+ }
+ } 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)
+ )
+ }
+ }
+ }
+
+ 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)
@@ -970,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
}
}
@@ -985,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 {
@@ -995,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 {
@@ -1020,19 +1457,10 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
-func networkErrorAlert(_ r: ChatResponse) -> Alert? {
- switch r {
- case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
- return mkAlert(
- title: "Connection timeout",
- message: "Please check your network connection with \(serverHostname(addr)) and try again."
- )
- case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
- return mkAlert(
- title: "Connection error",
- message: "Please check your network connection with \(serverHostname(addr)) and try again."
- )
- default:
+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
}
}
@@ -1040,7 +1468,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
- DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
+ await MainActor.run {
+ ChatModel.shared.replaceChat(contactRequest.id, chat)
+ NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ }
+ if contact.sndReady {
+ DispatchQueue.main.async {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(chat.id)
+ }
+ }
+ }
}
}
@@ -1081,10 +1519,16 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact))
}
-func apiGetCallInvitations() throws -> [RcvCallInvitation] {
- let r = chatSendCmdSync(.apiGetCallInvitations)
+func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
+ 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: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations)
+ if case let .callInvitations(invs) = r { return invs }
+ throw r.unexpected
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
@@ -1096,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 {
@@ -1132,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 MainActor.run { 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 {
@@ -1174,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 {
@@ -1211,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 []
}
@@ -1226,61 +1670,82 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact }
- .filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
+ .filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
}
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: 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.unexpected
+}
+
+func getAgentServersSummary() throws -> PresentedServersSummary {
+ let userId = try currentUserId("getAgentServersSummary")
+ 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.unexpected
+}
+
+func resetAgentServersStats() async throws {
+ try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 {
@@ -1297,16 +1762,25 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
defer { m.ctrlInitInProgress = false }
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations)
if m.chatDbStatus != .ok { return }
+ NetworkObserver.shared.restartMonitor()
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if encryptionStartedDefault.get() {
encryptionStartedDefault.set(false)
}
- try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
- try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
+ try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
- try apiSetPQEncryption(pqExperimentalEnabledDefault.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)
@@ -1351,31 +1825,34 @@ 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())
- let justStarted = try apiStartChat()
+ let chatRunning = try apiCheckChatRunning()
m.users = try listUsers()
- if justStarted {
+ if !chatRunning {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
- try refreshCallInvitations()
+ Task { try await refreshCallInvitations() }
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
+ _ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
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
+ }
}
}
}
@@ -1388,8 +1865,7 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
logger.debug("startChatWithTemporaryDatabase")
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
- try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
- try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
+ try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser
}
@@ -1435,24 +1911,37 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
- m.chats = chats.map { Chat.init($0) }
+ 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.chats = chats.map { Chat.init($0) }
+ m.updateChats(chats)
+ tm.activeFilter = nil
+ tm.userTags = tags
+ tm.updateChatTags(m.chats)
}
} else {
await MainActor.run {
m.userAddress = nil
- m.chats = []
+ m.updateChats([])
+ tm.activeFilter = nil
+ tm.userTags = []
+ tm.presetTags = [:]
}
}
}
@@ -1462,6 +1951,8 @@ class ChatReceiver {
private var receiveMessages = true
private var _lastMsgTime = Date.now
+ var messagesChannel: ((APIResult) -> Void)? = nil
+
static let shared = ChatReceiver()
var lastMsgTime: Date { get { _lastMsgTime } }
@@ -1478,7 +1969,15 @@ 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)
+ }
}
_ = try? await Task.sleep(nanoseconds: 7_500_000)
}
@@ -1492,11 +1991,9 @@ 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)")
switch res {
case let .contactDeletedByContact(user, contact):
@@ -1519,7 +2016,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(user, contact)
}
await MainActor.run {
- m.setContactNetworkStatus(contact, .connected)
+ n.setContactNetworkStatus(contact, .connected)
}
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
@@ -1531,6 +2028,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
+ case let .contactSndReady(user, contact):
+ if active(user) && contact.directOrUsed {
+ await MainActor.run {
+ m.updateContact(contact)
+ if let conn = contact.activeConn {
+ m.dismissConnReqView(conn.id)
+ m.removeChat(conn.id)
+ }
+ }
+ }
+ await MainActor.run {
+ n.setContactNetworkStatus(contact, .connected)
+ }
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1563,7 +2073,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run {
if m.chatId == mergedContact.id {
- m.chatId = intoContact.id
+ ItemsModel.shared.loadOpenChat(mergedContact.id)
}
m.removeChat(mergedContact.id)
}
@@ -1571,59 +2081,71 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .networkStatus(status, connections):
// dispatch queue to synchronize access
networkStatusesLock.sync {
- var ns = m.networkStatuses
+ var ns = n.networkStatuses
// slow loop is on the background thread
for cId in connections {
ns[cId] = status
}
// fast model update is on the main thread
DispatchQueue.main.sync {
- m.networkStatuses = ns
+ n.networkStatuses = ns
}
}
case let .networkStatuses(_, statuses): ()
// dispatch queue to synchronize access
networkStatusesLock.sync {
- var ns = m.networkStatuses
+ var ns = n.networkStatuses
// slow loop is on the background thread
for s in statuses {
ns[s.agentConnId] = s.networkStatus
}
// fast model update is on the main thread
DispatchQueue.main.sync {
- m.networkStatuses = ns
+ 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):
@@ -1634,23 +2156,33 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
}
- case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
+ case let .chatItemsDeleted(user, items, _):
if !active(user) {
- if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
- await MainActor.run {
- m.decreaseUnreadCounter(user: user)
+ for item in items {
+ let d = item.deletedChatItem
+ if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) {
+ await MainActor.run {
+ m.decreaseUnreadCounter(user: user)
+ }
}
}
return
}
await MainActor.run {
- if let toChatItem = toChatItem {
- _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
- } else {
- m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
+ for item in items {
+ if let toChatItem = item.toChatItem {
+ _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
+ } 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 {
@@ -1670,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 {
@@ -1678,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):
@@ -1728,7 +2278,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
if let contact = memberContact {
await MainActor.run {
- m.setContactNetworkStatus(contact, .connected)
+ n.setContactNetworkStatus(contact, .connected)
}
}
case let .groupUpdated(user, toGroup):
@@ -1759,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):
@@ -1770,11 +2324,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
- case let .rcvFileError(user, aChatItem, _):
+ case let .rcvFileError(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
+ case let .rcvFileWarning(user, aChatItem, _, _):
+ if let aChatItem = aChatItem {
+ await chatItemSimpleUpdate(user, aChatItem)
+ }
case let .sndFileStart(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _):
@@ -1791,12 +2349,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
- Task { cleanupFile(aChatItem) }
- case let .sndFileError(user, aChatItem, _):
+ case let .sndFileError(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
+ case let .sndFileWarning(user, aChatItem, _, _):
+ if let aChatItem = aChatItem {
+ await chatItemSimpleUpdate(user, aChatItem)
+ }
case let .callInvitation(invitation):
await MainActor.run {
m.callInvitations[invitation.contact.id] = invitation
@@ -1806,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)
@@ -1842,21 +2402,35 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case .chatSuspended:
chatSuspended()
- case let .contactSwitch(_, contact, switchProgress):
- await MainActor.run {
- m.updateContactConnectionStats(contact, switchProgress.connectionStats)
+ case let .contactSwitch(user, contact, switchProgress):
+ if active(user) {
+ await MainActor.run {
+ m.updateContactConnectionStats(contact, switchProgress.connectionStats)
+ }
}
- case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
- await MainActor.run {
- m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
+ case let .groupMemberSwitch(user, groupInfo, member, switchProgress):
+ if active(user) {
+ await MainActor.run {
+ m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
+ }
}
- case let .contactRatchetSync(_, contact, ratchetSyncProgress):
- await MainActor.run {
- m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
+ case let .contactRatchetSync(user, contact, ratchetSyncProgress):
+ if active(user) {
+ await MainActor.run {
+ m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
+ }
}
- case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
- await MainActor.run {
- m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
+ case let .groupMemberRatchetSync(user, groupInfo, member, ratchetSyncProgress):
+ if active(user) {
+ await MainActor.run {
+ m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
+ }
+ }
+ case let .contactDisabled(user, contact):
+ if active(user) {
+ await MainActor.run {
+ m.updateContact(contact)
+ }
}
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible):
await MainActor.run {
@@ -1880,12 +2454,30 @@ func processReceivedMsg(_ res: ChatResponse) async {
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
}
- case .remoteCtrlStopped:
+ case let .remoteCtrlStopped(_, rcStopReason):
// This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession {
await MainActor.run {
m.remoteCtrlSession = nil
+ dismissAllSheets() {
+ switch rcStopReason {
+ case .disconnected:
+ ()
+ case .connectionFailed(.errorAgent(.RCP(.identity))):
+ AlertManager.shared.showAlertMsg(
+ title: "Connection with desktop stopped",
+ message: "This link was used with another mobile device, please create a new link on the desktop."
+ )
+ default:
+ AlertManager.shared.showAlert(Alert(
+ title: Text("Connection with desktop stopped"),
+ message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
+ primaryButton: .default(Text("Ok")),
+ secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
+ ))
+ }
+ }
}
if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -1914,12 +2506,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
func switchToLocalSession() {
let m = ChatModel.shared
+ let n = NetworkModel.shared
m.remoteCtrlSession = nil
do {
m.users = try listUsers()
try getUserChatData()
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
- m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
+ n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error {
logger.debug("error updating chat data: \(responseError(error))")
}
@@ -1942,32 +2535,79 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
}
}
-func refreshCallInvitations() throws {
+func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async {
let m = ChatModel.shared
- let callInvitations = try justRefreshCallInvitations()
- if let (chatId, ntfAction) = m.ntfCallInvitationAction,
- let invitation = m.callInvitations.removeValue(forKey: chatId) {
- m.ntfCallInvitationAction = nil
- CallController.shared.callAction(invitation: invitation, action: ntfAction)
- } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
- activateCall(invitation)
+ 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 justRefreshCallInvitations() throws -> [RcvCallInvitation] {
+func refreshCallInvitations() async throws {
let m = ChatModel.shared
- let callInvitations = try apiGetCallInvitations()
- m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
- return callInvitations
+ let callInvitations = try await apiGetCallInvitations()
+ await MainActor.run {
+ m.callInvitations = callsByChat(callInvitations)
+ if let (chatId, ntfAction) = m.ntfCallInvitationAction,
+ let invitation = m.callInvitations.removeValue(forKey: chatId) {
+ m.ntfCallInvitationAction = nil
+ CallController.shared.callAction(invitation: invitation, action: ntfAction)
+ } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
+ activateCall(invitation)
+ }
+ }
+}
+
+func justRefreshCallInvitations() async throws {
+ let callInvitations = try apiGetCallInvitationsSync()
+ await MainActor.run {
+ ChatModel.shared.callInvitations = callsByChat(callInvitations)
+ }
+}
+
+private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
+ callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
+ result, inv in result[inv.contact.id] = inv
+ }
}
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/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift
index 4494adc0e8..92bcdcac53 100644
--- a/apps/ios/Shared/Model/SuspendChat.swift
+++ b/apps/ios/Shared/Model/SuspendChat.swift
@@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) {
}
}
+let seSubscriber = seMessageSubscriber {
+ switch $0 {
+ case let .state(state):
+ switch state {
+ case .inactive:
+ if AppChatState.shared.value.inactive { activateChat() }
+ case .sendingMessage:
+ if AppChatState.shared.value.canSuspend { suspendChat() }
+ }
+ }
+}
+
func suspendChat() {
suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout)
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 7d69466c07..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 {
@@ -39,9 +40,14 @@ struct SimpleXApp: App {
// so that it's computed by the time view renders, and not on event after rendering
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
.environmentObject(chatModel)
+ .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
@@ -57,6 +63,7 @@ struct SimpleXApp: App {
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
+ AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) {
case .background:
// --- authentication
@@ -80,10 +87,27 @@ struct SimpleXApp: App {
if appState != .stopped {
startChatAndActivate {
- if appState.inactive && chatModel.chatRunning == true {
- updateChats()
- if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
- 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
}
}
}
@@ -128,16 +152,17 @@ struct SimpleXApp: App {
}
}
- private func updateChats() {
+ private func updateChats() async {
do {
- let chats = try apiGetChats()
- chatModel.updateChats(with: chats)
+ let chats = try await apiGetChatsAsync()
+ await MainActor.run { chatModel.updateChats(chats) }
if let id = chatModel.chatId,
- let chat = chatModel.getChat(id) {
- loadChat(chat: chat)
+ let chat = chatModel.getChat(id),
+ !NtfManager.shared.navigatingToChat {
+ Task { await loadChat(chat: chat, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
- chatModel.ntfContactRequest = nil
+ await MainActor.run { chatModel.ntfContactRequest = nil }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
@@ -147,9 +172,9 @@ struct SimpleXApp: App {
}
}
- private func updateCallInvitations() {
+ private func updateCallInvitations() async {
do {
- try refreshCallInvitations()
+ try await refreshCallInvitations()
} catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}
diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift
new file mode 100644
index 0000000000..de67390026
--- /dev/null
+++ b/apps/ios/Shared/Theme/Theme.swift
@@ -0,0 +1,199 @@
+//
+// Theme.swift
+// SimpleX (iOS)
+//
+// Created by Avently on 14.06.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+import SimpleXChat
+
+var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+
+var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
+var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
+
+func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
+
+class AppTheme: ObservableObject, Equatable {
+ static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
+
+ var name: String
+ var base: DefaultTheme
+ @ObservedObject var colors: Colors
+ @ObservedObject var appColors: AppColors
+ @ObservedObject var wallpaper: AppWallpaper
+
+ init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
+ self.name = name
+ self.base = base
+ self.colors = colors
+ self.appColors = appColors
+ self.wallpaper = wallpaper
+ }
+
+ static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
+ lhs.name == rhs.name &&
+ lhs.colors == rhs.colors &&
+ lhs.appColors == rhs.appColors &&
+ lhs.wallpaper == rhs.wallpaper
+ }
+
+ func updateFromCurrentColors() {
+ objectWillChange.send()
+ name = CurrentColors.name
+ base = CurrentColors.base
+ colors.updateColorsFrom(CurrentColors.colors)
+ appColors.updateColorsFrom(CurrentColors.appColors)
+ wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
+ }
+}
+
+struct ThemedBackground: ViewModifier {
+ @EnvironmentObject var theme: AppTheme
+ var grouped: Bool = false
+
+ func body(content: Content) -> some View {
+ content
+ .background(
+ theme.base == DefaultTheme.SIMPLEX
+ ? LinearGradient(
+ colors: [
+ grouped
+ ? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
+ : theme.colors.background.lighter(0.4),
+ grouped
+ ? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
+ : theme.colors.background.darker(0.4)
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ : LinearGradient(
+ colors: [],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .background(
+ theme.base == DefaultTheme.SIMPLEX
+ ? Color.clear
+ : grouped
+ ? theme.colors.background.asGroupedBackground(theme.base.mode)
+ : theme.colors.background
+ )
+ }
+}
+
+var systemInDarkThemeCurrently: Bool {
+ return UITraitCollection.current.userInterfaceStyle == .dark
+}
+
+func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
+ if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
+ // Change active colors from light to dark and back based on system theme
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
+ }
+}
+
+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 = imageFromBase64(image),
+ let filename = saveWallpaperFile(image: parsed) {
+ var copy = self
+ copy.image = nil
+ copy.imageFile = filename
+ return copy
+ } else {
+ return ThemeWallpaper()
+ }
+ } else {
+ return self
+ }
+ }
+
+ func withFilledWallpaperBase64() -> ThemeWallpaper {
+ let aw = toAppWallpaper()
+ let type = aw.type
+ 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 { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
+ return ThemeWallpaper (
+ preset: preset,
+ scale: scale,
+ scaleType: scaleType,
+ background: aw.background?.toReadableHex(),
+ tint: aw.tint?.toReadableHex(),
+ image: image,
+ imageFile: nil
+ )
+ }
+}
+
+extension ThemeModeOverride {
+ func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
+ let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
+ let w: ThemeWallpaper
+ switch wallpaperType {
+ case let WallpaperType.preset(filename, scale):
+ let p = PresetWallpaper.from(filename)
+ w = ThemeWallpaper(
+ preset: filename,
+ scale: scale ?? wallpaper?.scale,
+ scaleType: nil,
+ background: p?.background[base]?.toReadableHex(),
+ tint: p?.tint[base]?.toReadableHex(),
+ image: nil,
+ imageFile: nil
+ )
+ case WallpaperType.image:
+ w = ThemeWallpaper(
+ preset: nil,
+ scale: nil,
+ scaleType: WallpaperScaleType.fill,
+ background: Color.clear.toReadableHex(),
+ tint: Color.clear.toReadableHex(),
+ image: nil,
+ imageFile: nil
+ )
+ default:
+ w = ThemeWallpaper()
+ }
+ let wallpaper: ThemeWallpaper? = if let wallpaper {
+ ThemeWallpaper(
+ preset: wallpaper.preset,
+ scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
+ scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
+ background: wallpaper.background != w.background ? wallpaper.background : nil,
+ tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
+ image: wallpaper.image,
+ imageFile: wallpaper.imageFile
+ )
+ } else {
+ nil
+ }
+ return ThemeModeOverride(
+ mode: self.mode,
+ colors: ThemeColors(
+ primary: colors.primary != tc.primary ? colors.primary : nil,
+ primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
+ secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
+ secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
+ background: colors.background != tc.background ? colors.background : nil,
+ surface: colors.surface != tc.surface ? colors.surface : nil,
+ title: colors.title != tc.title ? colors.title : nil,
+ primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
+ sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
+ sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
+ receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
+ receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
+ ),
+ wallpaper: wallpaper
+ )
+ }
+}
diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift
new file mode 100644
index 0000000000..4166619d04
--- /dev/null
+++ b/apps/ios/Shared/Theme/ThemeManager.swift
@@ -0,0 +1,303 @@
+//
+// ThemeManager.swift
+// SimpleX (iOS)
+//
+// Created by Avently on 03.06.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+import SimpleXChat
+
+class ThemeManager {
+ struct ActiveTheme: Equatable {
+ let name: String
+ let base: DefaultTheme
+ let colors: Colors
+ let appColors: AppColors
+ var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
+
+ func toAppTheme() -> AppTheme {
+ AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
+ }
+ }
+
+ private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
+ switch systemDarkThemeDefault.get() {
+ case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
+ case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
+ case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
+ default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
+ }
+ }
+
+ private static func nonSystemThemeName() -> String {
+ let themeName = currentThemeDefault.get()
+ return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
+ themeName
+ } else {
+ systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
+ }
+ }
+
+ static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
+ let nonSystemThemeName = nonSystemThemeName()
+ let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
+ return appSettingsTheme.getTheme(defaultThemeId)
+ }
+
+ static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
+ let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
+ if let perUserTheme {
+ return perUserTheme
+ }
+ let defaultTheme = defaultActiveTheme(appSettingsTheme)
+ return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
+ }
+
+ static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
+ let themeName = currentThemeDefault.get()
+ let nonSystemThemeName = nonSystemThemeName()
+ let defaultTheme = defaultActiveTheme(appSettingsTheme)
+
+ let baseTheme = switch nonSystemThemeName {
+ case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
+ case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
+ case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
+ case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
+ default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
+ }
+
+ let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
+ let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
+
+ if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
+ return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
+ }
+ let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
+ PresetWallpaper.from(filename)?.colors[baseTheme.base]
+ } else if let wallpaper = perChatTheme?.wallpaper {
+ if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
+ } else if let wallpaper = perUserTheme?.wallpaper {
+ if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
+ } else {
+ if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
+ }
+
+ let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
+ let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
+ return ActiveTheme(
+ name: themeName,
+ base: baseTheme.base,
+ colors: colors,
+ appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
+ wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
+ )
+ }
+
+ static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
+ let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
+ let wType = current.wallpaper.type
+ let wBackground = current.wallpaper.background
+ let wTint = current.wallpaper.tint
+ let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
+ nil
+ } else {
+ ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
+ }
+ return ThemeOverrides(
+ themeId: "",
+ base: current.base,
+ colors: ThemeColors.from(current.colors, current.appColors),
+ wallpaper: w
+ )
+ }
+
+ static func applyTheme(_ theme: String) {
+ currentThemeDefault.set(theme)
+ CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ AppTheme.shared.updateFromCurrentColors()
+ let tint = UIColor(CurrentColors.colors.primary)
+ if SceneDelegate.windowStatic?.tintColor != tint {
+ SceneDelegate.windowStatic?.tintColor = tint
+ }
+// applyNavigationBarColors(CurrentColors.toAppTheme())
+ }
+
+ static func adjustWindowStyle() {
+ let style = switch currentThemeDefault.get() {
+ case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
+ case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
+ default: UIUserInterfaceStyle.dark
+ }
+ if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
+ SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
+ }
+ }
+
+// static func applyNavigationBarColors(_ theme: AppTheme) {
+// let baseColors = switch theme.base {
+// case DefaultTheme.LIGHT: LightColorPaletteApp
+// case DefaultTheme.DARK: DarkColorPaletteApp
+// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
+// case DefaultTheme.BLACK: BlackColorPaletteApp
+// }
+// let isDefaultColor = baseColors.title == theme.appColors.title
+//
+// let title = UIColor(theme.appColors.title)
+// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
+// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
+// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
+// } else {
+// UINavigationBar.appearance().titleTextAttributes = nil
+// UINavigationBar.appearance().largeTitleTextAttributes = nil
+// }
+// }
+
+ static func changeDarkTheme(_ theme: String) {
+ systemDarkThemeDefault.set(theme)
+ CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ AppTheme.shared.updateFromCurrentColors()
+ }
+
+ static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
+ let nonSystemThemeName = baseTheme.themeName
+ let pref = pref ?? themeOverridesDefault
+ let overrides = pref.get()
+ let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
+ let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
+ pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
+ var themeIds = currentThemeIdsDefault.get()
+ themeIds[nonSystemThemeName] = prevValue.themeId
+ currentThemeIdsDefault.set(themeIds)
+ applyTheme(currentThemeDefault.get())
+ }
+
+ static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding) {
+ pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
+ }
+
+ static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
+ let nonSystemThemeName = baseTheme.themeName
+ let pref = pref ?? themeOverridesDefault
+ let overrides = pref.get()
+ let theme = overrides.sameTheme(type, baseTheme.themeName)
+ var prevValue = theme ?? ThemeOverrides(base: baseTheme)
+ prevValue.wallpaper = if let type {
+ if case WallpaperType.empty = type {
+ nil as ThemeWallpaper?
+ } else {
+ ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
+ }
+ } else {
+ nil
+ }
+ pref.set(overrides.replace(prevValue))
+ var themeIds = currentThemeIdsDefault.get()
+ themeIds[nonSystemThemeName] = prevValue.themeId
+ currentThemeIdsDefault.set(themeIds)
+ applyTheme(currentThemeDefault.get())
+ }
+
+ static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding) -> Bool {
+ let overrides = themeOverridesDefault.get()
+ let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
+ wallpaper
+ } else {
+ overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
+ }
+ guard let sameWallpaper else {
+ if let type {
+ var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
+ w.scale = nil
+ w.scaleType = nil
+ w.background = nil
+ w.tint = nil
+ pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
+ } else {
+ // Make an empty wallpaper to override any top level ones
+ pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
+ }
+ return true
+ }
+ var type = sameWallpaper.toAppWallpaper().type
+ if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
+ // same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
+ if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
+ type = WallpaperType.image(filename, scale, scaleType)
+ } else {
+ logger.error("Error while copying wallpaper from global overrides to chat overrides")
+ return false
+ }
+ }
+ var prevValue = pref.wrappedValue
+ var w = ThemeWallpaper.from(type, nil, nil)
+ w.scale = nil
+ w.scaleType = nil
+ w.background = nil
+ w.tint = nil
+ prevValue.colors = ThemeColors()
+ prevValue.wallpaper = w
+ pref.wrappedValue = prevValue
+ return true
+ }
+
+ static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding) {
+ var prevValue = pref.wrappedValue
+ prevValue.wallpaper = if let type {
+ ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
+ } else {
+ nil
+ }
+ pref.wrappedValue = prevValue
+ }
+
+ static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
+ let wallpaper = theme.wallpaper?.importFromString()
+ let nonSystemThemeName = theme.base.themeName
+ let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
+ let overrides = pref.get()
+ var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
+ if let imageFile = prevValue.wallpaper?.imageFile {
+ try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
+ }
+ prevValue.base = theme.base
+ prevValue.colors = theme.colors
+ prevValue.wallpaper = wallpaper
+ pref.set(overrides.replace(prevValue))
+ currentThemeDefault.set(nonSystemThemeName)
+ var currentThemeIds = currentThemeIdsDefault.get()
+ currentThemeIds[nonSystemThemeName] = prevValue.themeId
+ currentThemeIdsDefault.set(currentThemeIds)
+ applyTheme(nonSystemThemeName)
+ }
+
+ static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
+ let nonSystemThemeName = nonSystemThemeName()
+ let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
+ let overrides = pref.get()
+ guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
+ var prevValue = overrides.getTheme(themeId)
+ else { return }
+ prevValue.colors = ThemeColors()
+ prevValue.wallpaper?.background = nil
+ prevValue.wallpaper?.tint = nil
+ pref.set(overrides.replace(prevValue))
+ applyTheme(currentThemeDefault.get())
+ }
+
+ static func resetAllThemeColors(_ pref: Binding) {
+ var prevValue = pref.wrappedValue
+ prevValue.colors = ThemeColors()
+ prevValue.wallpaper?.background = nil
+ prevValue.wallpaper?.tint = nil
+ pref.wrappedValue = prevValue
+ }
+
+ static func removeTheme(_ themeId: String?) {
+ var themes = themeOverridesDefault.get().map { $0 }
+ themes.removeAll(where: { $0.themeId == themeId })
+ themeOverridesDefault.set(themes)
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift
index 9f246f63f3..ab7a47b944 100644
--- a/apps/ios/Shared/Views/Call/ActiveCallView.swift
+++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift
@@ -9,6 +9,7 @@
import SwiftUI
import WebKit
import SimpleXChat
+import AVFoundation
struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel
@@ -16,33 +17,49 @@ 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
+ @State var wasConnected = false
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)
}
}
@@ -52,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()
@@ -69,6 +89,11 @@ struct ActiveCallView: View {
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false)
client?.endCall()
+ CallSoundsPlayer.shared.stop()
+ try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
+ if (wasConnected) {
+ CallSoundsPlayer.shared.vibrate(long: true)
+ }
}
.background(m.activeCallViewIsCollapsed ? .clear : .black)
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
@@ -77,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)
}
@@ -92,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)
@@ -103,12 +128,17 @@ struct ActiveCallView: View {
call.callState = .invitationSent
call.localCapabilities = capabilities
}
+ if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
+ try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
+ }
+ CallSoundsPlayer.shared.startConnectingCallSound()
+ activeCallWaitDeliveryReceipt()
}
case let .offer(offer, iceCandidates, capabilities):
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))")
}
@@ -126,6 +156,7 @@ struct ActiveCallView: View {
}
await MainActor.run {
call.callState = .negotiated
+ CallSoundsPlayer.shared.stop()
}
}
case let .ice(iceCandidates):
@@ -144,9 +175,16 @@ struct ActiveCallView: View {
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected
call.connectedAt = .now
+ if !wasConnected {
+ CallSoundsPlayer.shared.vibrate(long: false)
+ wasConnected = true
+ }
}
if state.connectionState == "closed" {
closeCallView(client)
+ if let callUUID = m.activeCall?.callUUID {
+ CallController.shared.endCall(callUUID: callUUID)
+ }
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
@@ -161,10 +199,22 @@ struct ActiveCallView: View {
call.callState = .connected
call.connectionInfo = connectionInfo
call.connectedAt = .now
+ if !wasConnected {
+ 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:
@@ -187,6 +237,44 @@ struct ActiveCallView: View {
}
}
+ private func activeCallWaitDeliveryReceipt() {
+ ChatReceiver.shared.messagesChannel = { msg in
+ guard let call = ChatModel.shared.activeCall, call.callState == .invitationSent else {
+ ChatReceiver.shared.messagesChannel = nil
+ return
+ }
+ 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
@@ -198,38 +286,20 @@ struct ActiveCallOverlay: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var call: Call
var client: WebRTCClient
+ @ObservedObject private var deviceManager = CallAudioDeviceManager.shared
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()
- 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
@@ -239,31 +309,45 @@ struct ActiveCallOverlay: View {
.foregroundColor(.white.opacity(0.8))
}
VStack {
- ProfileImage(imageStr: call.contact.profile.image)
- .scaledToFit()
- .frame(width: 192, height: 192)
+ ProfileImage(imageStr: call.contact.profile.image, size: 192)
audioCallInfoView(call)
}
.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()
- toggleSpeakerButton()
- .frame(maxWidth: .infinity, alignment: .trailing)
- }
- .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 {
+ deviceManager.start()
+ }
+ .onDisappear {
+ deviceManager.stop()
+ }
}
private func audioCallInfoView(_ call: Call) -> some View {
@@ -277,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: ")")
}
}
}
@@ -306,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: ")")
}
}
}
@@ -317,85 +401,122 @@ 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 {
- client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
- DispatchQueue.main.async {
- call.speakerEnabled = !call.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
+ //call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
}
}
- 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/AudioDevicePicker.swift b/apps/ios/Shared/Views/Call/AudioDevicePicker.swift
new file mode 100644
index 0000000000..be41741ab5
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/AudioDevicePicker.swift
@@ -0,0 +1,25 @@
+//
+// MPVolumeView.swift
+// SimpleX (iOS)
+//
+// Created by Avently on 24.04.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+import UIKit
+import AVKit
+
+struct AudioDevicePicker: UIViewRepresentable {
+ func makeUIView(context: Context) -> some UIView {
+ let v = AVRoutePickerView(frame: .zero)
+ v.activeTintColor = .white
+ v.tintColor = .white
+ return v
+ }
+
+ func updateUIView(_ uiView: UIViewType, context: Context) {
+
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift b/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift
new file mode 100644
index 0000000000..d56849d16a
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift
@@ -0,0 +1,67 @@
+//
+// CallAudioDeviceManager.swift
+// SimpleX (iOS)
+//
+// Created by Avently on 23.04.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+import SimpleXChat
+import AVKit
+import WebRTC
+
+class CallAudioDeviceManager: ObservableObject {
+ static let shared = CallAudioDeviceManager()
+ let audioSession: AVAudioSession
+ let nc = NotificationCenter.default
+
+ var call: Call?
+ var timer: Timer? = nil
+
+ // Actually, only one output
+ @Published var outputs: [AVAudioSessionPortDescription]
+ @Published var currentDevice: AVAudioSessionPortDescription? = nil
+ // All devices that can record audio (the ones that can play audio are not included)
+ @Published var availableInputs: [AVAudioSessionPortDescription] = []
+
+
+ init(_ audioSession: AVAudioSession? = nil) {
+ self.audioSession = audioSession ?? RTCAudioSession.sharedInstance().session
+ self.outputs = self.audioSession.currentRoute.outputs
+ self.availableInputs = self.audioSession.availableInputs ?? []
+ }
+
+ func reloadDevices() {
+ outputs = audioSession.currentRoute.outputs
+ currentDevice = audioSession.currentRoute.outputs.first
+ availableInputs = audioSession.availableInputs ?? []
+ call?.speakerEnabled = currentDevice?.portType == .builtInSpeaker
+
+
+ // Workaround situation:
+ // have bluetooth device connected, choosing speaker, disconnecting bluetooth device. In this case iOS will not post notification, so do it manually
+ timer?.invalidate()
+ if availableInputs.contains(where: { $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker }) {
+ timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { t in
+ self.reloadDevices()
+ }
+ }
+ }
+
+ @objc func audioCallback(notification: Notification) {
+ reloadDevices()
+
+ logger.debug("Changes in devices, current audio devices: \(String(describing: self.availableInputs.map({ $0.portType.rawValue }))), output: \(String(describing: self.currentDevice?.portType.rawValue))")
+ }
+
+ func start() {
+ nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil)
+ }
+
+ func stop() {
+ nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
+ timer?.invalidate()
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift
index 6da8294ef8..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,7 +121,23 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
do {
- try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
+ 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])
+ }
+ // Without any delay sound is not playing from speaker or external device in incoming call
+ Task {
+ for i in 0 ... 3 {
+ try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
+ if let preferred = audioSession.preferredInputDevice() {
+ await MainActor.run { try? audioSession.setPreferredInput(preferred) }
+ } else if hasVideo {
+ await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
+ }
+ }
+ }
logger.debug("audioSession category set")
try audioSession.setActive(true)
logger.debug("audioSession activated")
@@ -140,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)")
@@ -155,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)
@@ -189,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,
@@ -223,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)
@@ -245,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() {
@@ -256,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
@@ -272,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)
@@ -291,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)
}
@@ -312,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 c2d5dabd48..5479a9fada 100644
--- a/apps/ios/Shared/Views/Call/IncomingCallView.swift
+++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
@ObservedObject var cc = CallController.shared
var body: some View {
@@ -30,21 +31,21 @@ struct IncomingCallView: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if m.users.count > 1 {
- ProfileImage(imageStr: invitation.user.image, color: .white)
- .frame(width: 24, height: 24)
+ ProfileImage(imageStr: invitation.user.image, size: 24, color: .white)
}
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Text(invitation.callTypeText)
}
HStack {
ProfilePreview(profileOf: invitation.contact, color: .white)
+ .padding(.vertical, 6)
Spacer()
callButton("Reject", "phone.down.fill", .red) {
cc.endCall(invitation: invitation)
}
- callButton("Ignore", "multiply", .accentColor) {
+ callButton("Ignore", "multiply", .primary) {
cc.activeCallInvitation = nil
}
@@ -64,7 +65,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
+ .modifier(ThemedBackground())
.onAppear { dismissAllSheets() }
}
@@ -77,7 +78,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24)
Text(text)
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
.frame(minWidth: 44)
})
diff --git a/apps/ios/Shared/Views/Call/SoundPlayer.swift b/apps/ios/Shared/Views/Call/SoundPlayer.swift
index 17c13ab403..c7803a0cb8 100644
--- a/apps/ios/Shared/Views/Call/SoundPlayer.swift
+++ b/apps/ios/Shared/Views/Call/SoundPlayer.swift
@@ -8,6 +8,7 @@
import Foundation
import AVFoundation
+import UIKit
class SoundPlayer {
static let shared = SoundPlayer()
@@ -43,3 +44,63 @@ class SoundPlayer {
audioPlayer = nil
}
}
+
+class CallSoundsPlayer {
+ static let shared = CallSoundsPlayer()
+ private var audioPlayer: AVAudioPlayer?
+ private var playerTask: Task = Task {}
+
+ private func start(_ soundName: String, delayMs: Double) {
+ audioPlayer?.stop()
+ playerTask.cancel()
+ logger.debug("start \(soundName)")
+ guard let path = Bundle.main.path(forResource: soundName, ofType: "mp3", inDirectory: "sounds") else {
+ logger.debug("start: file not found")
+ return
+ }
+ do {
+ let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
+ if player.prepareToPlay() {
+ audioPlayer = player
+ }
+ } catch {
+ logger.debug("start: AVAudioPlayer error \(error.localizedDescription)")
+ }
+
+ playerTask = Task {
+ while let player = audioPlayer {
+ player.play()
+ do {
+ try await Task.sleep(nanoseconds: UInt64((player.duration * 1_000_000_000) + delayMs * 1_000_000))
+ } catch {
+ break
+ }
+ }
+ }
+ }
+
+ func startConnectingCallSound() {
+ start("connecting_call", delayMs: 0)
+ }
+
+ func startInCallSound() {
+ // Taken from https://github.com/TelegramOrg/Telegram-Android
+ // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
+ start("in_call", delayMs: 1000)
+ }
+
+ func stop() {
+ playerTask.cancel()
+ audioPlayer?.stop()
+ audioPlayer = nil
+ }
+
+ func vibrate(long: Bool) {
+ // iOS just don't want to vibrate more than once after a short period of time, and all 'styles' feel the same
+ if long {
+ AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
+ } else {
+ UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift
index 919b1e14e7..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
}
}
}
@@ -431,17 +453,18 @@ struct RTCIceServer: Codable, Equatable {
}
// the servers are expected in this format:
-// stun:stun.simplex.im:443?transport=tcp
-// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
+// stuns:stun.simplex.im:443?transport=tcp
+// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp
func parseRTCIceServer(_ str: String) -> RTCIceServer? {
var s = replaceScheme(str, "stun:")
+ s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:")
if let u: URL = URL(string: s),
let scheme = u.scheme,
let host = u.host,
let port = u.port,
- u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns") {
+ u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns") {
let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
return RTCIceServer(
urls: ["\(scheme):\(host):\(port)\(query)"],
diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift
index 1806984d64..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 {
@@ -49,68 +58,77 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
private let rtcAudioSession = RTCAudioSession.sharedInstance()
- private let audioQueue = DispatchQueue(label: "audio")
+ 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()
- logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}")
+ logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)")
super.init()
}
let defaultIceServers: [WebRTC.RTCIceServer] = [
- WebRTC.RTCIceServer(urlStrings: ["stun:stun.simplex.im:443"]),
- WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
- WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
+ WebRTC.RTCIceServer(urlStrings: ["stuns:stun.simplex.im:443"]),
+ //WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
+ WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
]
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,19 +626,30 @@ 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() {
- guard let call = activeCall.wrappedValue else { return }
+ if #available(iOS 16.0, *) {
+ _endCall()
+ } else {
+ // Fixes `connection.close()` getting locked up in iOS15
+ DispatchQueue.global(qos: .utility).async { self._endCall() }
+ }
+ }
+
+ private func _endCall() {
+ (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 {
@@ -428,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
}
}
@@ -489,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()
@@ -514,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: ()
}
}
}
@@ -537,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))
}
}
@@ -592,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 }
@@ -605,9 +891,23 @@ extension WebRTCClient {
self.rtcAudioSession.unlockForConfiguration()
}
do {
- try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue)
- try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
- try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
+ let hasExternalAudioDevice = self.rtcAudioSession.session.hasExternalAudioDevice()
+ 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 && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
+ try self.rtcAudioSession.setPreferredInput(preferred)
+ } else {
+ try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
+ }
+ } else {
+ try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
+ try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
+ try self.rtcAudioSession.overrideOutputAudioPort(.none)
+ }
+ if hasExternalAudioDevice && !skipExternalDevice {
+ logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
+ }
try self.rtcAudioSession.setActive(true)
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success")
} catch let error {
@@ -636,25 +936,70 @@ 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: ()
- }
- if let call = activeCall.wrappedValue {
- startCaptureLocalVideo(call)
+ let device = activeCall?.device ?? notConnectedCall?.device
+ if activeCall != nil {
+ activeCall?.device = device == .front ? .back : .front
+ } else {
+ notConnectedCall?.device = device == .front ? .back : .front
}
+ startCaptureLocalVideo(
+ activeCall?.device ?? notConnectedCall?.device,
+ (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
+ )
+ }
+}
+
+extension AVAudioSession {
+ func hasExternalAudioDevice() -> Bool {
+ availableInputs?.allSatisfy({ $0.portType == .builtInMic }) != true
}
- private func setTrackEnabled(_ type: T.Type, _ enabled: Bool) {
- activeCall.wrappedValue?.connection.transceivers
- .compactMap { $0.sender.track as? T }
- .forEach { $0.isEnabled = enabled }
+ func preferredInputDevice() -> AVAudioSessionPortDescription? {
+// logger.debug("Preferred input device: \(String(describing: self.availableInputs?.filter({ $0.portType != .builtInMic })))")
+ return availableInputs?.filter({ $0.portType != .builtInMic }).last
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index d0f4b6e55a..62a41c504a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -9,10 +9,9 @@
import SwiftUI
import SimpleXChat
-let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
-let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var imageSize: CGFloat = 32
@@ -25,30 +24,30 @@ struct ChatInfoToolbar: View {
}
ChatInfoImage(
chat: chat,
- color: colorScheme == .dark
- ? chatImageColorDark
- : chatImageColorLight
+ size: imageSize,
+ color: Color(uiColor: .tertiaryLabel)
)
- .frame(width: imageSize, height: imageSize)
.padding(.trailing, 4)
- VStack {
- let t = Text(cInfo.displayName).font(.headline)
- (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
- .lineLimit(1)
- if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
- Text(cInfo.fullName).font(.subheadline)
- .lineLimit(1)
+ let t = Text(cInfo.displayName).font(.headline)
+ (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
+ .lineLimit(1)
+ .if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
+ VStack(spacing: 0) {
+ v
+ Text(cInfo.fullName).font(.subheadline)
+ .lineLimit(1)
+ .padding(.top, -2)
+ }
}
- }
}
- .foregroundColor(.primary)
+ .foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
}
@@ -57,5 +56,6 @@ struct ChatInfoToolbar: View {
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
+ .environmentObject(CurrentColors.toAppTheme())
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 8b60fc7649..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 {
@@ -36,20 +36,20 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
}
}
-@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
+@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View {
if servers.count > 0 {
HStack {
Text(title).frame(width: 120, alignment: .leading)
Button(serverHost(servers[0])) {
UIPasteboard.general.string = servers.joined(separator: ";")
}
- .foregroundColor(.secondary)
+ .foregroundColor(secondaryColor)
.lineLimit(1)
}
}
}
-private func serverHost(_ s: String) -> String {
+func serverHost(_ s: String) -> String {
if let i = s.range(of: "@")?.lowerBound {
return String(s[i...].dropFirst())
} else {
@@ -90,29 +90,37 @@ enum SendReceipts: Identifiable, Hashable {
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
+ @ObservedObject var networkModel = NetworkModel.shared
@ObservedObject var chat: Chat
@State var contact: Contact
- @Binding var connectionStats: ConnectionStats?
- @Binding var customUserProfile: Profile?
@State var localAlias: String
- @Binding var connectionCode: String?
+ @State var featuresAllowed: ContactFeaturesAllowed
+ @State var currentFeaturesAllowed: ContactFeaturesAllowed
+ var onSearch: () -> Void
+ @State private var connectionStats: ConnectionStats? = nil
+ @State private var customUserProfile: Profile? = nil
+ @State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
- @State private var showDeleteContactActionSheet = false
+ @State private var actionSheet: SomeActionSheet? = nil
+ @State private var sheet: SomeSheet? = nil
+ @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
- @AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false
-
+
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
case networkStatusAlert
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
- case allowContactPQEncryptionAlert
- case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
+ case queueInfo(info: String)
+ case someAlert(alert: SomeAlert)
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -121,126 +129,172 @@ struct ChatInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
- case .allowContactPQEncryptionAlert: return "allowContactPQEncryptionAlert"
+ case let .queueInfo(info): return "queueInfo \(info)"
+ case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
}
}
}
-
+
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)
-
- if let customUserProfile = customUserProfile {
- Section("Incognito") {
- 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 {
- if let code = connectionCode { verifyCodeButton(code) }
- contactPreferencesButton()
- sendReceiptsOption()
- if let connStats = connectionStats,
- connStats.ratchetSyncAllowed {
- synchronizeConnectionButton()
+ .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)
+ }
+ }
}
-// } else if developerTools {
-// synchronizeConnectionButtonForce()
-// }
- }
- .disabled(!contact.ready || !contact.active)
-
- if pqExperimentalEnabled,
- let conn = contact.activeConn {
+
Section {
- infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
- if !conn.pqEncryption {
- allowPQButton()
+ if let code = connectionCode { verifyCodeButton(code) }
+ contactPreferencesButton()
+ sendReceiptsOption()
+ if let connStats = connectionStats,
+ connStats.ratchetSyncAllowed {
+ synchronizeConnectionButton()
}
- } header: {
- Text(String("Quantum resistant E2E encryption"))
- } footer: {
- if !conn.pqEncryption {
- Text(String("After allowing quantum resistant encryption, it will be enabled after several messages if your contact also allows it."))
- }
- }
- }
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
- if let contactLink = contact.contactLink {
- Section {
- SimpleXLinkQRCode(uri: contactLink)
- Button {
- showShareSheet(items: [simplexChatLink(contactLink)])
+ NavigationLink {
+ ChatWallpaperEditorSheet(chat: chat)
} label: {
- Label("Share address", systemImage: "square.and.arrow.up")
+ Label("Chat theme", systemImage: "photo")
}
- } header: {
- Text("Address")
- } footer: {
- Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
}
- }
-
- if contact.ready && contact.active {
- Section("Servers") {
- networkStatusRow()
- .onTapGesture {
- alert = .networkStatusAlert
+ .disabled(!contact.ready || !contact.active)
+
+ Section {
+ ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
+ } footer: {
+ Text("Delete chat messages from your device.")
+ }
+
+ 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)
+ }
+ }
+ }
+
+ 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) }
+ }
+ }
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
}
}
}
-
- Section {
- clearChatButton()
- deleteContactButton()
- }
-
- if developerTools {
- Section(header: Text("For console")) {
- 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)
}
}
- .navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear {
@@ -248,6 +302,23 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
+
+ Task {
+ do {
+ let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
+ let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
+ await MainActor.run {
+ connectionStats = stats
+ customUserProfile = profile
+ connectionCode = code
+ if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
+ chat.chatInfo = .direct(contact: ct)
+ }
+ }
+ } catch let error {
+ logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
+ }
+ }
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
@@ -255,46 +326,52 @@ 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 .allowContactPQEncryptionAlert: return allowContactPQEncryptionAlert()
+ 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)
}
}
- .actionSheet(isPresented: $showDeleteContactActionSheet) {
- if contact.ready && contact.active {
- return ActionSheet(
- title: Text("Delete contact?\nThis cannot be undone!"),
- buttons: [
- .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
- .destructive(Text("Delete")) { deleteContact(notify: false) },
- .cancel()
- ]
- )
+ .actionSheet(item: $actionSheet) { $0.actionSheet }
+ .sheet(item: $sheet) {
+ if #available(iOS 16.0, *) {
+ $0.content
+ .presentationDetents([.fraction($0.fraction)])
} else {
- return ActionSheet(
- title: Text("Delete contact?\nThis cannot be undone!"),
- buttons: [
- .destructive(Text("Delete")) { deleteContact() },
- .cancel()
- ]
+ $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 {
- VStack {
+ VStack(spacing: 8) {
let cInfo = chat.chatInfo
- ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
- .frame(width: 192, height: 192)
- .padding(.top, 12)
- .padding()
+ ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
+ .padding(.vertical, 12)
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(contact.profile.displayName)
.font(.largeTitle)
)
@@ -317,7 +394,7 @@ struct ChatInfoView: View {
}
.frame(maxWidth: .infinity, alignment: .center)
}
-
+
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
@@ -332,9 +409,9 @@ struct ChatInfoView: View {
setContactAlias()
}
.multilineTextAlignment(.center)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
-
+
private func setContactAlias() {
Task {
do {
@@ -349,6 +426,25 @@ struct ChatInfoView: View {
}
}
+ private func searchButton(width: CGFloat) -> some View {
+ InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
+ dismiss()
+ onSearch()
+ }
+ .disabled(!contact.ready || chat.chatItems.isEmpty)
+ }
+
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: false))",
+ width: width
+ ) {
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
+ }
+ .disabled(!contact.ready || !contact.active)
+ }
+
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
@@ -370,6 +466,7 @@ struct ChatInfoView: View {
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
+ .modifier(ThemedBackground(grouped: true))
} label: {
Label(
contact.verified ? "View security code" : "Verify security code",
@@ -377,21 +474,23 @@ struct ChatInfoView: View {
)
}
}
-
+
private func contactPreferencesButton() -> some 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))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Contact preferences", systemImage: "switch.2")
}
}
-
+
private func sendReceiptsOption() -> some View {
Picker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
@@ -405,7 +504,7 @@ struct ChatInfoView: View {
setSendReceipts()
}
}
-
+
private func setSendReceipts() {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.sendRcpts = sendReceipts.bool()
@@ -414,13 +513,18 @@ struct ChatInfoView: View {
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)
}
}
-
+
private func synchronizeConnectionButtonForce() -> some View {
Button {
alert = .syncConnectionForceAlert
@@ -429,45 +533,43 @@ struct ChatInfoView: View {
.foregroundColor(.red)
}
}
-
- private func allowPQButton() -> some View {
- Button {
- alert = .allowContactPQEncryptionAlert
- } label: {
- Label(String("Allow PQ encryption"), systemImage: "exclamationmark.triangle")
- .foregroundColor(.orange)
- }
- }
-
+
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
Image(systemName: "info.circle")
- .foregroundColor(.accentColor)
+ .foregroundColor(theme.colors.primary)
.font(.system(size: 14))
Spacer()
- Text(chatModel.contactNetworkStatus(contact).statusString)
- .foregroundColor(.secondary)
+ Text(networkModel.contactNetworkStatus(contact).statusString)
+ .foregroundColor(theme.colors.secondary)
serverImage()
}
}
-
+
private func serverImage() -> some View {
- let status = chatModel.contactNetworkStatus(contact)
+ let status = networkModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName)
- .foregroundColor(status == .connected ? .green : .secondary)
+ .foregroundColor(status == .connected ? .green : theme.colors.secondary)
.font(.system(size: 12))
}
-
+
private func deleteContactButton() -> some View {
Button(role: .destructive) {
- showDeleteContactActionSheet = true
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: true,
+ showAlert: { alert = .someAlert(alert: $0) },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
} label: {
- Label("Delete contact", systemImage: "trash")
+ Label("Delete contact", systemImage: "person.badge.minus")
.foregroundColor(Color.red)
}
}
-
+
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
@@ -476,26 +578,7 @@ struct ChatInfoView: View {
.foregroundColor(Color.orange)
}
}
-
- private func deleteContact(notify: Bool? = nil) {
- Task {
- do {
- try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
- await MainActor.run {
- dismiss()
- chatModel.chatId = nil
- chatModel.removeChat(chat.chatInfo.id)
- }
- } catch let error {
- logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
- let a = getErrorAlert(error, "Error deleting contact")
- await MainActor.run {
- alert = .error(title: a.title, error: a.message)
- }
- }
- }
- }
-
+
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -509,14 +592,14 @@ struct ChatInfoView: View {
secondaryButton: .cancel()
)
}
-
+
private func networkStatusAlert() -> Alert {
Alert(
title: Text("Network status"),
- message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
+ message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
)
}
-
+
private func switchContactAddress() {
Task {
do {
@@ -535,7 +618,7 @@ struct ChatInfoView: View {
}
}
}
-
+
private func abortSwitchContactAddress() {
Task {
do {
@@ -553,55 +636,439 @@ 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()
- }
- } 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)
+ 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))")
}
}
}
+}
- private func allowContactPQEncryption() {
- Task {
- do {
- let ct = try await apiSetContactPQ(contact.apiId, true)
- contact = ct
- await MainActor.run {
- chatModel.updateContact(ct)
- dismiss()
- }
- } catch let error {
- logger.error("allowContactPQEncryption apiSetContactPQ error: \(responseError(error))")
- let a = getErrorAlert(error, "Error allowing contact PQ encryption")
- await MainActor.run {
- alert = .error(title: a.title, error: a.message)
+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 allowContactPQEncryptionAlert() -> Alert {
- Alert(
- title: Text(String("Allow quantum resistant encryption?")),
- message: Text(String("This is an experimental feature, it is not recommended to enable it for important chats.")),
- primaryButton: .destructive(Text(String("Allow")), action: allowContactPQEncryption),
- secondaryButton: .cancel()
+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
+
+ var body: some View {
+ CallButton(
+ chat: chat,
+ contact: contact,
+ connectionStats: $connectionStats,
+ image: "phone.fill",
+ title: "call",
+ mediaType: .audio,
+ width: width,
+ showAlert: showAlert
)
}
}
+struct VideoButton: View {
+ var chat: Chat
+ var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
+ var width: CGFloat
+ var showAlert: (SomeAlert) -> Void
+
+ var body: some View {
+ CallButton(
+ chat: chat,
+ contact: contact,
+ connectionStats: $connectionStats,
+ image: "video.fill",
+ title: "video",
+ mediaType: .video,
+ width: width,
+ showAlert: showAlert
+ )
+ }
+}
+
+private struct CallButton: View {
+ var chat: Chat
+ var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
+ var image: String
+ var title: LocalizedStringKey
+ var mediaType: CallMediaType
+ var width: CGFloat
+ var showAlert: (SomeAlert) -> Void
+
+ var body: some View {
+ let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
+
+ InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
+ if canCall {
+ 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 {
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Can't call contact",
+ message: "Send message to enable calls."
+ ),
+ id: "can't call contact, send message"
+ ))
+ } else if !contact.active {
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Can't call contact",
+ message: "Contact is deleted."
+ ),
+ id: "can't call contact, contact deleted"
+ ))
+ } else if !contact.ready {
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Can't call contact",
+ message: "Connecting to contact, please wait or check later!"
+ ),
+ id: "can't call contact, contact not ready"
+ ))
+ } else if !chat.chatInfo.featureEnabled(.calls) {
+ switch chat.chatInfo.showEnableCallsAlert {
+ case .userEnable:
+ showAlert(SomeAlert(
+ alert: Alert(
+ title: Text("Allow calls?"),
+ message: Text("You need to allow your contact to call to be able to call them."),
+ primaryButton: .default(Text("Allow")) {
+ allowFeatureToContact(contact, .calls)
+ },
+ secondaryButton: .cancel()
+ ),
+ id: "allow calls"
+ ))
+ case .askContact:
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Calls prohibited!",
+ message: "Please ask your contact to enable calls."
+ ),
+ id: "calls prohibited, ask contact"
+ ))
+ case .other:
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Calls prohibited!",
+ message: "Please check yours and your contact preferences."
+ )
+ , id: "calls prohibited, other"
+ ))
+ }
+ } else {
+ showAlert(SomeAlert(
+ alert: mkAlert(title: "Can't call contact"),
+ id: "can't call contact"
+ ))
+ }
+ }
+ .disabled(ChatModel.shared.activeCall != nil)
+ }
+}
+
+let infoViewActionButtonHeight: CGFloat = 60
+
+struct InfoViewButton: View {
+ var image: String
+ var title: LocalizedStringKey
+ var disabledLook: Bool = false
+ var width: CGFloat
+ var action: () -> Void
+
+ var body: some View {
+ VStack(spacing: 4) {
+ Image(systemName: image)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ Text(title)
+ .font(.caption)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .foregroundColor(.accentColor)
+ .background(Color(.secondarySystemGroupedBackground))
+ .cornerRadius(10.0)
+ .frame(width: width, height: infoViewActionButtonHeight)
+ .disabled(disabledLook)
+ .onTapGesture(perform: action)
+ }
+}
+
+struct ChatWallpaperEditorSheet: View {
+ @Environment(\.dismiss) var dismiss
+ @EnvironmentObject var theme: AppTheme
+ @State private var globalThemeUsed: Bool = false
+ @State var chat: Chat
+ @State private var themes: ThemeModeOverrides
+
+ init(chat: Chat) {
+ self.chat = chat
+ self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
+ uiThemes
+ } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
+ uiThemes
+ } else {
+ ThemeModeOverrides()
+ }
+ }
+
+ var body: some View {
+ let preferred = themes.preferredMode(!theme.colors.isLight)
+ let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ ChatWallpaperEditor(
+ initialTheme: initialTheme,
+ themeModeOverride: initialTheme,
+ applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
+ globalThemeUsed: $globalThemeUsed,
+ save: { applyToMode, newTheme in
+ await save(applyToMode, newTheme, $chat)
+ }
+ )
+ .navigationTitle("Chat theme")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ globalThemeUsed = preferred == nil
+ }
+ .onChange(of: theme.base.mode) { _ in
+ globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
+ }
+ .onChange(of: ChatModel.shared.chatId) { _ in
+ dismiss()
+ }
+ }
+
+ private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
+ if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
+ uiThemes
+ } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
+ uiThemes
+ } else {
+ ThemeModeOverrides()
+ }
+ }
+
+ private static var updateBackendTask: Task = Task {}
+ private func save(
+ _ applyToMode: DefaultThemeMode?,
+ _ newTheme: ThemeModeOverride?,
+ _ chat: Binding
+ ) async {
+ let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
+ var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
+ var changedThemes: ThemeModeOverrides? = unchangedThemes
+ let light: ThemeModeOverride? = if let newTheme {
+ ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
+ } else {
+ nil
+ }
+ let dark: ThemeModeOverride? = if let newTheme {
+ ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
+ } else {
+ nil
+ }
+
+ if let applyToMode {
+ switch applyToMode {
+ case DefaultThemeMode.light:
+ changedThemes?.light = light
+ case DefaultThemeMode.dark:
+ changedThemes?.dark = dark
+ }
+ } else {
+ changedThemes?.light = light
+ changedThemes?.dark = dark
+ }
+ if changedThemes?.light != nil || changedThemes?.dark != nil {
+ let light = changedThemes?.light
+ let dark = changedThemes?.dark
+ let currentMode = CurrentColors.base.mode
+ // same image file for both modes, copy image to make them as different files
+ if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
+ let imageFile = if currentMode == DefaultThemeMode.light {
+ darkImageFile
+ } else {
+ lightImageFile
+ }
+ let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
+ if currentMode == DefaultThemeMode.light {
+ dark.wallpaper?.imageFile = filePath
+ changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
+ } else {
+ light.wallpaper?.imageFile = filePath
+ changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
+ }
+ }
+ } else {
+ changedThemes = nil
+ }
+ wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
+ wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
+ wallpaperFiles.forEach(removeWallpaperFile)
+
+ let changedThemesConstant = changedThemes
+ ChatWallpaperEditorSheet.updateBackendTask.cancel()
+ ChatWallpaperEditorSheet.updateBackendTask = Task {
+ do {
+ try await Task.sleep(nanoseconds: 300_000000)
+ if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
+ if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
+ contact.uiThemes = changedThemesConstant
+ await MainActor.run {
+ ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
+ chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
+ themes = themesFromChat(chat.wrappedValue)
+ }
+ } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
+ groupInfo.uiThemes = changedThemesConstant
+
+ await MainActor.run {
+ ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
+ chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
+ themes = themesFromChat(chat.wrappedValue)
+ }
+ }
+ }
+ } catch {
+ // canceled task
+ }
+ }
+ }
+}
+
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
Alert(
title: Text("Change receiving address?"),
@@ -629,15 +1096,267 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
)
}
+func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
+ let (rcvMsgInfo, qInfo) = info
+ var msgInfo: String
+ if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
+ return String.localizedStringWithFormat(NSLocalizedString("server queue info: %@\n\nlast received msg: %@", comment: "queue info"), encodeJSON(qInfo), msgInfo)
+}
+
+func queueInfoAlert(_ info: String) -> Alert {
+ Alert(
+ title: Text("Message queue info"),
+ message: Text(info),
+ primaryButton: .default(Text("Ok")),
+ secondaryButton: .default(Text("Copy")) { UIPasteboard.general.string = info }
+ )
+}
+
+func deleteContactDialog(
+ _ chat: Chat,
+ _ contact: Contact,
+ dismissToChatList: Bool,
+ showAlert: @escaping (SomeAlert) -> Void,
+ showActionSheet: @escaping (SomeActionSheet) -> Void,
+ showSheetContent: @escaping (SomeSheet) -> Void
+) {
+ if contact.sndReady && contact.active && !contact.chatDeleted {
+ deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
+ } else if contact.sndReady && contact.active && contact.chatDeleted {
+ deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
+ } else { // !(contact.sndReady && contact.active)
+ deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
+ }
+}
+
+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,
+ _ dismissToChatList: Bool,
+ _ showAlert: @escaping (SomeAlert) -> Void,
+ _ showActionSheet: @escaping (SomeActionSheet) -> Void,
+ _ showSheetContent: @escaping (SomeSheet) -> Void
+) {
+ showActionSheet(SomeActionSheet(
+ actionSheet: ActionSheet(
+ title: Text("Delete contact?"),
+ buttons: [
+ .destructive(Text("Only delete conversation")) {
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
+ },
+ .destructive(Text("Delete contact")) {
+ showSheetContent(SomeSheet(
+ content: { AnyView(
+ DeleteActiveContactDialog(
+ chat: chat,
+ contact: contact,
+ dismissToChatList: dismissToChatList,
+ showAlert: showAlert
+ )
+ ) },
+ id: "DeleteActiveContactDialog"
+ ))
+ },
+ .cancel()
+ ]
+ ),
+ id: "deleteContactOrConversationDialog"
+ ))
+}
+
+private func deleteContactMaybeErrorAlert(
+ _ chat: Chat,
+ _ contact: Contact,
+ chatDeleteMode: ChatDeleteMode,
+ _ dismissToChatList: Bool,
+ _ showAlert: @escaping (SomeAlert) -> Void
+) {
+ Task {
+ let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
+ if let alert = alert_ {
+ showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
+ } else {
+ if dismissToChatList {
+ await MainActor.run {
+ ChatModel.shared.chatId = nil
+ }
+ DispatchQueue.main.async {
+ dismissAllSheets(animated: true) {
+ if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
+ AlertManager.shared.showAlert(deleteConversationNotice(contact))
+ } else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
+ AlertManager.shared.showAlert(deleteContactNotice(contact))
+ }
+ }
+ }
+ } else {
+ if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
+ showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
+ } else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
+ showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
+ }
+ }
+ }
+ }
+}
+
+private func deleteConversationNotice(_ contact: Contact) -> Alert {
+ return Alert(
+ title: Text("Conversation deleted!"),
+ message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
+ primaryButton: .default(Text("Don't show again")) {
+ showDeleteConversationNoticeDefault.set(false)
+ },
+ secondaryButton: .default(Text("Ok"))
+ )
+}
+
+private func deleteContactNotice(_ contact: Contact) -> Alert {
+ return Alert(
+ title: Text("Contact deleted!"),
+ message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
+ primaryButton: .default(Text("Don't show again")) {
+ showDeleteContactNoticeDefault.set(false)
+ },
+ secondaryButton: .default(Text("Ok"))
+ )
+}
+
+enum ContactDeleteMode {
+ case full
+ case entity
+
+ public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
+ switch self {
+ case .full: .full(notify: notify)
+ case .entity: .entity(notify: notify)
+ }
+ }
+}
+
+struct DeleteActiveContactDialog: View {
+ @Environment(\.dismiss) var dismiss
+ @EnvironmentObject var theme: AppTheme
+ var chat: Chat
+ var contact: Contact
+ var dismissToChatList: Bool
+ var showAlert: (SomeAlert) -> Void
+ @State private var keepConversation = false
+
+ var body: some View {
+ NavigationView {
+ List {
+ Section {
+ Toggle("Keep conversation", isOn: $keepConversation)
+
+ Button(role: .destructive) {
+ dismiss()
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
+ } label: {
+ Text("Delete without notification")
+ }
+
+ Button(role: .destructive) {
+ dismiss()
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
+ } label: {
+ Text("Delete and notify contact")
+ }
+ } footer: {
+ Text("Contact will be deleted - this cannot be undone!")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ var contactDeleteMode: ContactDeleteMode {
+ keepConversation ? .entity : .full
+ }
+}
+
+private func deleteContactWithoutConversation(
+ _ chat: Chat,
+ _ contact: Contact,
+ _ dismissToChatList: Bool,
+ _ showAlert: @escaping (SomeAlert) -> Void,
+ _ showActionSheet: @escaping (SomeActionSheet) -> Void
+) {
+ showActionSheet(SomeActionSheet(
+ actionSheet: ActionSheet(
+ title: Text("Confirm contact deletion?"),
+ buttons: [
+ .destructive(Text("Delete and notify contact")) {
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
+ },
+ .destructive(Text("Delete without notification")) {
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
+ },
+ .cancel()
+ ]
+ ),
+ id: "deleteContactWithoutConversation"
+ ))
+}
+
+private func deleteNotReadyContact(
+ _ chat: Chat,
+ _ contact: Contact,
+ _ dismissToChatList: Bool,
+ _ showAlert: @escaping (SomeAlert) -> Void,
+ _ showActionSheet: @escaping (SomeActionSheet) -> Void
+) {
+ showActionSheet(SomeActionSheet(
+ actionSheet: ActionSheet(
+ title: Text("Confirm contact deletion?"),
+ buttons: [
+ .destructive(Text("Confirm")) {
+ deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
+ },
+ .cancel()
+ ]
+ ),
+ id: "deleteNotReadyContact"
+ ))
+}
+
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
- connectionStats: Binding.constant(nil),
- customUserProfile: Binding.constant(nil),
localAlias: "",
- connectionCode: Binding.constant(nil)
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
+ onSearch: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
index bcdeb7fd9c..30f5e7a589 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
@@ -9,6 +9,7 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
+ var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
- convenience init(image: UIImage) {
+ convenience init(image: UIImage, contentMode: UIView.ContentMode) {
self.init()
self.image = image
+ self.cMode = contentMode
imageView = UIImageView(gifImage: image)
- imageView!.contentMode = .scaleAspectFit
+ imageView!.contentMode = contentMode
self.addSubview(imageView!)
}
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
- imageView!.contentMode = .scaleAspectFit
+ imageView!.contentMode = contentMode
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
+ private let contentMode: UIView.ContentMode
- init(image: UIImage) {
+ init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
self.image = image
+ self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
- AnimatedImageView(image: image)
+ AnimatedImageView(image: image, contentMode: contentMode)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
index f0bf43dffe..0283e9c07e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var status: CICallStatus
@@ -22,7 +23,7 @@ struct CICallItemView: View {
switch status {
case .pending:
if sent {
- Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
+ Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
} else {
acceptCallButton()
}
@@ -35,9 +36,7 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange)
}
- chatItem.timestampText
- .font(.caption)
- .foregroundColor(.secondary)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
@@ -51,28 +50,28 @@ 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(.secondary)
+ Text(durationText(duration)).foregroundColor(theme.colors.secondary)
}
}
@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(.secondary)
+ 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 03afa30331..02be8af73b 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
@@ -11,14 +11,17 @@ 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
var body: some View {
- if !revealed, let fs = mergedFeautures() {
+ if !revealed, let fs = mergedFeatures() {
HStack {
ForEach(fs, content: featureIconView)
}
@@ -47,12 +50,12 @@ struct CIChatFeatureView: View {
}
}
- private func mergedFeautures() -> [FeatureInfo]? {
+ private func mergedFeatures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set = []
if var i = m.getChatItemIndex(chatItem) {
- while i < m.reversedChatItems.count,
- let f = featureInfo(m.reversedChatItems[i]) {
+ while i < im.reversedChatItems.count,
+ let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
@@ -65,10 +68,10 @@ struct CIChatFeatureView: View {
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content {
- case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
- case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
- case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
- case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
+ case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
+ case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
+ case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
+ case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
default: nil
}
}
@@ -80,7 +83,7 @@ struct CIChatFeatureView: View {
if let param = f.param {
HStack {
i
- chatEventText(Text(param)).lineLimit(1)
+ chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
}
} else {
i
@@ -92,7 +95,7 @@ struct CIChatFeatureView: View {
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
- chatEventText(chatItem)
+ chatEventText(chatItem, theme.colors.secondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
@@ -103,6 +106,9 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
- CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
+ 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/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
index b6be3f837e..1375b87a5a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
@@ -17,6 +17,7 @@ struct CIEventView: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
.textSelection(.disabled)
+ .lineLimit(4)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
index e52a92a3c6..67f7b69e2c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
@@ -19,15 +20,15 @@ struct CIFeaturePreferenceView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact,
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()
}
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
if let acceptText {
r = r
+ Text(acceptText)
.fontWeight(.medium)
- .foregroundColor(.accentColor)
- + Text(" ")
+ .foregroundColor(theme.colors.primary)
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
return r.font(.caption)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index c94ba3f830..b0b404d8b5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -11,15 +11,19 @@ import SimpleXChat
struct CIFileView: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
+ var smallViewSize: CGFloat?
var body: some View {
- let metaReserve = edited
- ? " "
- : " "
- Button(action: fileAction) {
+ if smallViewSize != nil {
+ fileIndicator()
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
+ } else {
+ let metaReserve = edited
+ ? " "
+ : " "
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
@@ -30,12 +34,12 @@ struct CIFileView: View {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
- .foregroundColor(.primary)
+ .foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
@@ -45,45 +49,43 @@ struct CIFileView: View {
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
+ .disabled(!itemInteractive)
}
- .disabled(!itemInteractive)
}
+ @inline(__always)
private var itemInteractive: Bool {
if let file = file {
switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local
case .sndTransfer: return false
- case .sndComplete: return false
+ case .sndComplete: return true
case .sndCancelled: return false
- case .sndError: return false
+ case .sndError: return true
+ case .sndWarning: return true
case .rcvInvitation: return true
case .rcvAccepted: return true
case .rcvTransfer: return false
+ case .rcvAborted: return true
case .rcvComplete: return true
case .rcvCancelled: return false
- case .rcvError: return false
+ case .rcvError: return true
+ case .rcvWarning: return true
case .invalid: return false
}
}
return false
}
- private func fileSizeValid() -> Bool {
- if let file = file {
- return file.fileSize <= getMaxFileSize(file.fileProtocol)
- }
- return false
- }
-
private func fileAction() {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
- case .rcvInvitation:
- if fileSizeValid() {
+ case .rcvInvitation, .rcvAborted:
+ if fileSizeValid(file) {
Task {
- logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
+ logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
}
@@ -114,11 +116,28 @@ struct CIFileView: View {
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
+ case let .rcvError(rcvFileError):
+ logger.debug("CIFileView fileAction - in .rcvError")
+ showFileErrorAlert(rcvFileError)
+ case let .rcvWarning(rcvFileError):
+ logger.debug("CIFileView fileAction - in .rcvWarning")
+ showFileErrorAlert(rcvFileError, temporary: true)
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
+ case .sndComplete:
+ logger.debug("CIFileView fileAction - in .sndComplete")
+ if let fileSource = getLoadedFileSource(file) {
+ saveCryptoFile(fileSource)
+ }
+ case let .sndError(sndFileError):
+ logger.debug("CIFileView fileAction - in .sndError")
+ showFileErrorAlert(sndFileError)
+ case let .sndWarning(sndFileError):
+ logger.debug("CIFileView fileAction - in .sndWarning")
+ showFileErrorAlert(sndFileError, temporary: true)
default: break
}
}
@@ -142,9 +161,10 @@ struct CIFileView: View {
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation:
- if fileSizeValid() {
- fileIcon("arrow.down.doc.fill", color: .accentColor)
+ if fileSizeValid(file) {
+ fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
}
@@ -155,9 +175,12 @@ struct CIFileView: View {
} else {
progressView()
}
+ case .rcvAborted:
+ fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
}
} else {
@@ -166,21 +189,22 @@ struct CIFileView: View {
}
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
- ZStack(alignment: .center) {
+ let size = smallViewSize ?? 30
+ return ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
- .frame(width: 30, height: 30)
+ .frame(width: size, height: size)
.foregroundColor(color)
if let innerIcon = innerIcon,
- let innerIconSize = innerIconSize {
+ let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
- .padding(.top, 12)
+ .padding(.top, size / 2.5)
}
}
}
@@ -201,6 +225,13 @@ struct CIFileView: View {
}
}
+func fileSizeValid(_ file: CIFile?) -> Bool {
+ if let file = file {
+ return file.fileSize <= getMaxFileSize(file.fileProtocol)
+ }
+ return false
+}
+
func saveCryptoFile(_ fileSource: CryptoFile) {
if let cfArgs = fileSource.cryptoArgs {
let url = getAppFilePath(fileSource.filePath)
@@ -225,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(
@@ -242,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 72013877ca..3fcf578875 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
@@ -11,7 +11,9 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
+ @ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole
@@ -20,6 +22,8 @@ struct CIGroupInvitationView: View {
@State private var inProgress = false
@State private var progressByTimeout = false
+ @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
+
var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) {
@@ -37,16 +41,22 @@ struct CIGroupInvitationView: View {
VStack(alignment: .leading, spacing: 2) {
groupInvitationText()
.overlay(DetermineWidth())
- Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
- .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
- .font(.callout)
- .padding(.trailing, 60)
- .overlay(DetermineWidth())
+ (
+ Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
+ .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
+ .font(.callout)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ )
+ .overlay(DetermineWidth())
}
} else {
- groupInvitationText()
- .padding(.trailing, 60)
- .overlay(DetermineWidth())
+ (
+ groupInvitationText()
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ )
+ .overlay(DetermineWidth())
}
}
.padding(.bottom, 2)
@@ -56,14 +66,11 @@ struct CIGroupInvitationView: View {
}
}
- chatItem.timestampText
- .font(.caption)
- .foregroundColor(.secondary)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, colorScheme))
- .cornerRadius(18)
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -77,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
@@ -92,7 +99,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View {
var color: Color
if action && !inProgress {
- color = chatIncognito ? .indigo : .accentColor
+ color = chatIncognito ? .indigo : theme.colors.primary
} else {
color = Color(uiColor: .tertiaryLabel)
}
@@ -100,9 +107,9 @@ struct CIGroupInvitationView: View {
ProfileImage(
imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill",
+ size: 44,
color: color
)
- .frame(width: 44, height: 44)
.padding(.trailing, 4)
VStack(alignment: .leading) {
let p = groupInvitation.groupProfile
@@ -115,7 +122,7 @@ struct CIGroupInvitationView: View {
}
}
- private func groupInvitationText() -> some View {
+ private func groupInvitationText() -> Text {
Text(groupInvitationStr())
.font(.callout)
}
@@ -137,8 +144,8 @@ struct CIGroupInvitationView: View {
struct CIGroupInvitationView_Previews: PreviewProvider {
static var previews: some View {
Group {
- CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
- CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
+ CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
+ CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index 16974147c8..d30369339d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -11,34 +11,44 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem
- let image: String
+ var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
+ var preview: UIImage?
let maxWidth: CGFloat
- @Binding var imgWidth: CGFloat?
- @State var scrollProxy: ScrollViewProxy?
- @State var metaColor: Color
- @State private var showFullScreenImage = false
+ var imgWidth: CGFloat?
+ var smallView: Bool = false
+ @Binding var showFullScreenImage: Bool
+ @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
- imageView(uiImage)
+ Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
- FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
+ FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
+ }
+ .if(!smallView) { view in
+ view.modifier(PrivacyBlur(blurred: $blurred))
+ }
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
}
- .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
- } else if let data = Data(base64Encoded: dropImagePrefix(image)),
- let uiImage = UIImage(data: data) {
- imageView(uiImage)
- .onTapGesture {
+ } else if let preview {
+ Group {
+ if smallView {
+ smallViewImageView(preview)
+ } else {
+ imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
+ }
+ }
+ .simultaneousGesture(TapGesture().onEnded {
if let file = file {
switch file.fileStatus {
- case .rcvInvitation:
+ case .rcvInvitation, .rcvAborted:
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
@@ -61,17 +71,27 @@ struct CIImageView: View {
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
+ case let .rcvError(rcvFileError):
+ showFileErrorAlert(rcvFileError)
+ case let .rcvWarning(rcvFileError):
+ showFileErrorAlert(rcvFileError, temporary: true)
+ case let .sndError(sndFileError):
+ showFileErrorAlert(sndFileError)
+ case let .sndWarning(sndFileError):
+ showFileErrorAlert(sndFileError, temporary: true)
default: ()
}
}
- }
+ })
}
}
+ .onDisappear {
+ showFullScreenImage = false
+ }
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
- DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
@@ -83,7 +103,26 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
- loadingIndicator()
+ if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
+ loadingIndicator()
+ }
+ }
+ }
+
+ private func smallViewImageView(_ img: UIImage) -> some View {
+ ZStack(alignment: .topTrailing) {
+ if img.imageData == nil {
+ Image(uiImage: img)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: maxWidth, height: maxWidth)
+ } else {
+ SwiftyGif(image: img, contentMode: .scaleAspectFill)
+ .frame(width: maxWidth, height: maxWidth)
+ }
+ if chatItem.file?.showStatusIconInSmallView == true {
+ loadingIndicator()
+ }
}
}
@@ -100,13 +139,16 @@ struct CIImageView: View {
case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13)
case .sndError: fileIcon("xmark", 10, 13)
+ case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView()
+ case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
+ case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
+ case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
- default: EmptyView()
}
}
}
@@ -114,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(metaColor)
.padding(padding)
}
@@ -127,4 +169,12 @@ struct CIImageView: View {
.tint(.white)
.padding(8)
}
+
+ private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
+ switch fileStatus {
+ case .rcvInvitation: true
+ case .rcvAborted: true
+ default: false
+ }
+ }
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
index 0299a5e6f8..5e9fa691de 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
@@ -7,9 +7,11 @@
//
import SwiftUI
+import SimpleXChat
struct CIInvalidJSONView: View {
- var json: String
+ @EnvironmentObject var theme: AppTheme
+ var json: Data?
@State private var showJSON = false
var body: some View {
@@ -21,18 +23,17 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
- .cornerRadius(18)
.textSelection(.disabled)
- .onTapGesture { showJSON = true }
- .sheet(isPresented: $showJSON) {
- invalidJSONView(json)
+ .simultaneousGesture(TapGesture().onEnded { showJSON = true })
+ .appSheet(isPresented: $showJSON) {
+ 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")
@@ -44,10 +45,11 @@ func invalidJSONView(_ json: String) -> some View {
}
.frame(maxHeight: .infinity)
.padding()
+ .modifier(ThemedBackground())
}
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 4c12c7312a..f9dbaede63 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -10,36 +10,59 @@ import SwiftUI
import SimpleXChat
struct CILinkView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
+ @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
VStack(alignment: .center, spacing: 6) {
- if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
- let uiImage = UIImage(data: data) {
+ 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)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
.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 da82ed4dd2..2898a318a9 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@@ -19,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()
}
@@ -43,12 +43,12 @@ struct CIMemberCreatedContactView: View {
r = r
+ Text(openText)
.fontWeight(.medium)
- .foregroundColor(.accentColor)
- + Text(" ")
+ .foregroundColor(theme.colors.primary)
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
return r.font(.caption)
}
@@ -56,11 +56,11 @@ struct CIMemberCreatedContactView: View {
if let member = chatItem.memberDisplayName {
return Text(member + " " + chatItem.content.text + " ")
.fontWeight(.light)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
} else {
return Text(chatItem.content.text + " ")
.fontWeight(.light)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index c189abde24..fc73778239 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -11,97 +11,173 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
- var metaColor = Color.secondary
- var paleMetaColor = Color(UIColor.tertiaryLabel)
+ var metaColor: Color
+ var paleMetaColor = Color(uiColor: .tertiaryLabel)
+ var showStatus = true
+ var showEdited = true
+ var invertedMaterial = false
+
+ @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some 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)
- case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
+ 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)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
- }
- case .partial:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
- }
- }
- default:
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
}
}
}
}
-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, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
+func ciMetaText(
+ _ meta: CIMeta,
+ chatTTL: Int?,
+ encrypted: Bool?,
+ color: Color = .clear, // we use this function to reserve space without rendering meta
+ paleColor: Color? = nil,
+ primaryColor: Color = .accentColor,
+ colorMode: MetaColorMode = .normal,
+ onlyOverrides: Bool = false, // only render colors that differ from base
+ showStatus: Bool = true,
+ showEdited: Bool = true,
+ showViaProxy: Bool,
+ showTimesamp: Bool
+) -> Text {
var r = Text("")
- if meta.itemEdited {
- r = r + statusIconText("pencil", color)
+ 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", 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 let (icon, statusColor) = meta.statusIcon(color) {
- 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
+ if showViaProxy, meta.sentViaProxy == true {
+ appendSpace()
+ r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
+ }
+ if showStatus {
+ 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 + colored(Text(image), metaColor)
+ } else if !meta.disappearing {
+ r = r + colorMode.statusSpacer(meta.itemStatus.sent)
}
- r = r + Text(" ")
- } else if !meta.disappearing {
- r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
+ 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 {
+ static let metaColor = Color.secondary
static var previews: some View {
Group {
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
- CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
+ CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
}
.previewLayout(.fixed(width: 360, height: 100))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index 3ad45d6987..4e5713c263 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -13,18 +13,22 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
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
@State private var alert: CIRcvDecryptionErrorAlert?
+ @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
+
enum CIRcvDecryptionErrorAlert: Identifiable {
case syncAllowedAlert(_ syncConnection: () -> Void)
case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert
case decryptionErrorAlert
- case error(title: LocalizedStringKey, error: LocalizedStringKey)
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -44,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)
}
@@ -59,43 +63,46 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
- case let .error(title, error): return Alert(title: Text(title), message: Text(error))
+ case let .error(title, error): return mkAlert(title: title, message: error)
}
}
}
- @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 {
@@ -112,24 +119,22 @@ struct CIRcvDecryptionError: View {
}
(
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
- .foregroundColor(syncSupported ? .accentColor : .secondary)
+ .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
+ + textSpace
+ Text("Fix connection")
- .foregroundColor(syncSupported ? .accentColor : .secondary)
+ .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
}
.padding(.horizontal, 12)
- CIMetaView(chat: chat, chatItem: chatItem)
+ 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))
- .cornerRadius(18)
.textSelection(.disabled)
}
@@ -139,17 +144,15 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
.padding(.horizontal, 12)
- CIMetaView(chat: chat, chatItem: chatItem)
+ 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))
- .cornerRadius(18)
.textSelection(.disabled)
}
@@ -158,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 a3918e17bc..eacbe9360a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -13,95 +13,136 @@ import Combine
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem
- private let image: String
+ private let preview: UIImage?
@State private var duration: Int
@State private var progress: Int = 0
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
- @Binding private var videoWidth: CGFloat?
- @State private var scrollProxy: ScrollViewProxy?
- @State private var preview: UIImage? = nil
+ private var videoWidth: CGFloat?
+ private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
- @State private var showFullScreenPlayer = false
+ @Binding private var showFullScreenPlayer: Bool
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
+ private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
+ @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
- init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding, scrollProxy: ScrollViewProxy?) {
+ init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) {
self.chatItem = chatItem
- self.image = image
+ self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
- self._videoWidth = videoWidth
- self.scrollProxy = scrollProxy
- if let url = getLoadedVideo(chatItem.file) {
- let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
- self._urlDecrypted = State(initialValue: decrypted)
- if let decrypted = decrypted {
- self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
- self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
- }
- self._url = State(initialValue: url)
- }
- if let data = Data(base64Encoded: dropImagePrefix(image)),
- let uiImage = UIImage(data: data) {
- self._preview = State(initialValue: uiImage)
- }
+ self.videoWidth = videoWidth
+ self.smallView = smallView
+ self._showFullScreenPlayer = showFullscreenPlayer
}
var body: some View {
let file = chatItem.file
- ZStack {
+ ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
- 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 {
- videoViewEncrypted(file, defaultPreview, duration)
- } else if let data = Data(base64Encoded: dropImagePrefix(image)),
- let uiImage = UIImage(data: data) {
- imageView(uiImage)
- .onTapGesture {
- if let file = file {
- switch file.fileStatus {
- case .rcvInvitation:
- 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: ()
+ }
+ })
}
}
- durationProgress()
- }
- if let file = file, case .rcvInvitation = file.fileStatus {
- Button {
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- } label: {
- playPauseIcon("play.fill")
+ if !smallView {
+ durationProgress()
}
}
+ if !blurred, let file, showDownloadButton(file.fileStatus) {
+ if !smallView || !file.showStatusIconInSmallView {
+ playPauseIcon("play.fill")
+ .simultaneousGesture(TapGesture().onEnded {
+ receiveFileIfValidSize(file: file, receiveFile: receiveFile)
+ })
+ }
+ }
+ }
+ .fullScreenCover(isPresented: $showFullScreenPlayer) {
+ if let decrypted = urlDecrypted {
+ fullScreenPlayer(decrypted)
+ }
+ }
+ .onAppear {
+ setupPlayer(chatItem.file)
+ }
+ .onChange(of: chatItem.file) { file in
+ // ChatItem can be changed in small view on chat list screen
+ setupPlayer(file)
+ }
+ .onDisappear {
+ showFullScreenPlayer = false
+ }
+ }
+
+ private func setupPlayer(_ file: CIFile?) {
+ let newUrl = getLoadedVideo(file)
+ if newUrl == url {
+ return
+ }
+ url = nil
+ urlDecrypted = nil
+ player = nil
+ fullPlayer = nil
+ if let newUrl {
+ let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
+ urlDecrypted = decrypted
+ if let decrypted = decrypted {
+ player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
+ fullPlayer = AVPlayer(url: decrypted)
+ }
+ url = newUrl
+ }
+ }
+
+ private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
+ switch fileStatus {
+ case .rcvInvitation: true
+ case .rcvAborted: true
+ default: false
}
}
@@ -110,33 +151,29 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
- .fullScreenCover(isPresented: $showFullScreenPlayer) {
- if let decrypted = urlDecrypted {
- fullScreenPlayer(decrypted)
- }
- }
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
- }
+ })
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
- if !decryptionInProgress {
- Button {
- decrypt(file: file) {
- if let decrypted = urlDecrypted {
- videoPlaying = true
- player?.play()
- }
- }
- } label: {
+ if !blurred {
+ if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ decrypt(file: file) {
+ if urlDecrypted != nil {
+ videoPlaying = true
+ player?.play()
+ }
+ }
+ })
+ .disabled(!canBePlayed)
+ } else {
+ videoDecryptionProgress()
}
- .disabled(!canBePlayed)
- } else {
- videoDecryptionProgress()
}
}
}
@@ -144,7 +181,6 @@ struct CIVideoView: View {
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
- DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
@@ -156,35 +192,34 @@ struct CIVideoView: View {
videoPlaying = false
}
}
- .fullScreenCover(isPresented: $showFullScreenPlayer) {
- fullScreenPlayer(url)
- }
- .onTapGesture {
- switch player.timeControlStatus {
- case .playing:
- player.pause()
- videoPlaying = false
- case .paused:
- if canBePlayed {
- showFullScreenPlayer = true
+ .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
+ .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 {
- Button {
- m.stopPreviousRecPlay = url
- player.play()
- } label: {
- playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
- }
- .disabled(!canBePlayed)
+ if !videoPlaying && !blurred {
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ m.stopPreviousRecPlay = url
+ player.play()
+ })
+ .disabled(!canBePlayed)
}
}
- loadingIndicator()
+ fileStatusIcon()
}
.onAppear {
addObserver(player, url)
@@ -196,14 +231,53 @@ struct CIVideoView: View {
}
}
+ private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
+ return ZStack(alignment: .topLeading) {
+ let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
+ smallViewImageView(preview, file)
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
+ decrypt(file: file) {
+ showFullScreenPlayer = urlDecrypted != nil
+ }
+ }
+ .onChange(of: m.activeCallViewIsCollapsed) { _ in
+ showFullScreenPlayer = false
+ }
+ if file.showStatusIconInSmallView {
+ // Show nothing
+ } else if !decryptionInProgress {
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ } else {
+ videoDecryptionProgress()
+ }
+ }
+ }
+
+ private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
+ return ZStack(alignment: .topLeading) {
+ smallViewImageView(preview, file)
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
+ showFullScreenPlayer = true
+ }
+ .onChange(of: m.activeCallViewIsCollapsed) { _ in
+ showFullScreenPlayer = false
+ }
+
+ if !file.showStatusIconInSmallView {
+ playPauseIcon("play.fill")
+ }
+ }
+ }
+
+
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
- .frame(width: 12, height: 12)
+ .frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.foregroundColor(color)
- .padding(.leading, 4)
- .frame(width: 40, height: 40)
+ .padding(.leading, smallView ? 0 : 4)
+ .frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -211,50 +285,57 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
- .frame(width: 12, height: 12)
+ .frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.tint(color)
- .frame(width: 40, height: 40)
+ .frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.background(Color.black.opacity(0.35))
.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
- DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(width: w)
- loadingIndicator()
+ .modifier(PrivacyBlur(blurred: $blurred))
+ if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
+ fileStatusIcon()
+ }
}
}
- @ViewBuilder private func loadingIndicator() -> some View {
+ private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
+ ZStack(alignment: .center) {
+ Image(uiImage: img)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: maxWidth, height: maxWidth)
+ if file.showStatusIconInSmallView {
+ fileStatusIcon()
+ .allowsHitTesting(false)
+ }
+ }
+ }
+
+ @ViewBuilder private func fileStatusIcon() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
case .sndStored:
@@ -271,7 +352,16 @@ struct CIVideoView: View {
}
case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13)
- case .sndError: fileIcon("xmark", 10, 13)
+ case let .sndError(sndFileError):
+ fileIcon("xmark", 10, 13)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError)
+ })
+ case let .sndWarning(sndFileError):
+ fileIcon("exclamationmark.triangle.fill", 10, 13)
+ .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):
@@ -280,10 +370,20 @@ struct CIVideoView: View {
} else {
progressView()
}
+ case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
+ case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
- case .rcvError: fileIcon("xmark", 10, 13)
+ case let .rcvError(rcvFileError):
+ fileIcon("xmark", 10, 13)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError)
+ })
+ case let .rcvWarning(rcvFileError):
+ fileIcon("exclamationmark.triangle.fill", 10, 13)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError, temporary: true)
+ })
case .invalid: fileIcon("questionmark", 10, 13)
- default: EmptyView()
}
}
}
@@ -291,10 +391,10 @@ 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(padding)
+ .padding(smallView ? 0 : padding)
}
private func progressView() -> some View {
@@ -302,26 +402,24 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
- .padding(11)
+ .padding(smallView ? 0 : 11)
}
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], 11)
+ .padding([.trailing, .top], smallView ? 0 : 11)
}
// TODO encrypt: where file size is checked?
- private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
+ private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = m.currentUser {
- await receiveFile(user, file.fileId, false)
+ await receiveFile(user, file.fileId, false, false)
}
}
}
@@ -331,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()
@@ -354,7 +452,8 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
- m.stopPreviousRecPlay = url
+ // Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
+ if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer {
player.play()
var played = false
@@ -391,10 +490,12 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
- player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
+ if !smallView {
+ player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
+ }
fullPlayer = AVPlayer(url: decrypted)
}
- decryptionInProgress = true
+ decryptionInProgress = false
completed?()
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 3aecb65ebd..715e606a74 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -11,18 +11,30 @@ import SimpleXChat
struct CIVoiceView: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
- @Binding var audioPlayer: AudioPlayer?
- @Binding var playbackState: VoiceMessagePlaybackState
- @Binding var playbackTime: TimeInterval?
+ @State var audioPlayer: AudioPlayer? = nil
+ @State var playbackState: VoiceMessagePlaybackState = .noPlayback
+ @State var playbackTime: TimeInterval? = nil
+
@Binding var allowMenu: Bool
+ var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
- if chatItem.chatDir.sent {
+ if smallViewSize != nil {
+ HStack(spacing: 10) {
+ player()
+ playerTime()
+ .allowsHitTesting(false)
+ if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
+ playbackSlider()
+ }
+ }
+ } else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -53,7 +65,13 @@ struct CIVoiceView: View {
}
private func player() -> some View {
- VoiceMessagePlayer(
+ let sizeMultiplier: CGFloat = if let sz = smallViewSize {
+ voiceMessageSizeBasedOnSquareSize(sz) / 56
+ } else {
+ 1
+ }
+ return VoiceMessagePlayer(
+ chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -62,7 +80,8 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
- allowMenu: $allowMenu
+ allowMenu: $allowMenu,
+ sizeMultiplier: sizeMultiplier
)
}
@@ -72,7 +91,7 @@ struct CIVoiceView: View {
playbackState: $playbackState,
playbackTime: $playbackTime
)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
private func playbackSlider() -> some View {
@@ -89,10 +108,11 @@ struct CIVoiceView: View {
allowMenu = true
}
}
+ .tint(theme.colors.primary)
}
private func metaView() -> some View {
- CIMetaView(chat: chat, chatItem: chatItem)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
}
}
@@ -117,8 +137,9 @@ struct VoiceMessagePlayerTime: View {
}
struct VoiceMessagePlayer: View {
+ @ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var recordingFile: CIFile?
var recordingTime: TimeInterval
@@ -128,23 +149,49 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
+
@Binding var allowMenu: Bool
+ var sizeMultiplier: CGFloat
var body: some View {
ZStack {
if let recordingFile = recordingFile {
switch recordingFile.fileStatus {
- case .sndStored: playbackButton()
- case .sndTransfer: playbackButton()
+ case .sndStored:
+ if recordingFile.fileProtocol == .local {
+ playbackButton()
+ } else {
+ loadingIcon()
+ }
+ case .sndTransfer: loadingIcon()
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
- case .sndError: playbackButton()
- case .rcvInvitation: downloadButton(recordingFile)
+ case let .sndError(sndFileError):
+ fileStatusIcon("multiply", 14)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError)
+ })
+ case let .sndWarning(sndFileError):
+ fileStatusIcon("exclamationmark.triangle.fill", 16)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(sndFileError, temporary: true)
+ })
+ case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
+ case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
- case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
+ case let .rcvError(rcvFileError):
+ fileStatusIcon("multiply", 14)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError)
+ })
+ case let .rcvWarning(rcvFileError):
+ fileStatusIcon("exclamationmark.triangle.fill", 16)
+ .simultaneousGesture(TapGesture().onEnded {
+ showFileErrorAlert(rcvFileError, temporary: true)
+ })
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
@@ -152,84 +199,126 @@ struct VoiceMessagePlayer: View {
}
}
.onAppear {
+ if audioPlayer == nil {
+ let small = sizeMultiplier != 1
+ audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
+ playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
+ playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
+ }
seek = { to in audioPlayer?.seek(to) }
- audioPlayer?.onTimer = { playbackTime = $0 }
+ let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
+ getAppFilePath(recordingSource.filePath)
+ } else {
+ nil
+ }
+ let chatId = chatModel.chatId
+ let userId = chatModel.currentUser?.userId
+ audioPlayer?.onTimer = {
+ playbackTime = $0
+ notifyStateChange()
+ // Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
+ if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
+ stopPlayback()
+ }
+ }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
+ notifyStateChange()
+ }
+ // One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
+ if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
+ stopPlayback()
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
- audioPlayer?.stop()
- playbackState = .noPlayback
- playbackTime = TimeInterval(0)
+ stopPlayback()
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
+ // Notify activeContentPreview in ChatPreviewView that playback is finished
+ if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
+ chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
+ chatModel.stopPreviousRecPlay = nil
+ }
+ }
+ .onChange(of: chatModel.chatId) { _ in
+ stopPlayback()
+ }
+ .onDisappear {
+ if sizeMultiplier == 1 && chatModel.chatId == nil {
+ stopPlayback()
+ }
}
}
- @ViewBuilder private func playbackButton() -> some View {
- switch playbackState {
- case .noPlayback:
- Button {
- if let recordingSource = getLoadedFileSource(recordingFile) {
- startPlayback(recordingSource)
+ 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)
+ }
+ case .playing:
+ audioPlayer?.pause()
+ playbackState = .paused
+ notifyStateChange()
+ case .paused:
+ audioPlayer?.play()
+ playbackState = .playing
+ notifyStateChange()
}
- } label: {
- playPauseIcon("play.fill")
- }
- case .playing:
- Button {
- audioPlayer?.pause()
- playbackState = .paused
- } label: {
- playPauseIcon("pause.fill")
- }
- case .paused:
- Button {
- audioPlayer?.play()
- playbackState = .playing
- } label: {
- playPauseIcon("play.fill")
- }
- }
+ })
}
- private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
+ private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
ZStack {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
- .frame(width: 20, height: 20)
+ .frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
- .frame(width: 56, height: 56)
- .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
+ .frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
+ .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
- .frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
+ .frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
- private func downloadButton(_ recordingFile: CIFile) -> some View {
- Button {
- Task {
- if let user = chatModel.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
+ private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
+ playPauseIcon(icon, theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded {
+ Task {
+ if let user = chatModel.currentUser {
+ await receiveFile(user: user, fileId: recordingFile.fileId)
+ }
}
- }
- } label: {
- playPauseIcon("play.fill")
+ })
+ }
+
+ func notifyStateChange() {
+ if sizeMultiplier != 1 {
+ VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
+ } else {
+ VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
}
}
private struct ProgressCircle: View {
+ @EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -237,7 +326,7 @@ struct VoiceMessagePlayer: View {
Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke(
- Color.accentColor,
+ theme.colors.primary,
style: StrokeStyle(lineWidth: 3)
)
.rotationEffect(.degrees(-90))
@@ -245,26 +334,103 @@ struct VoiceMessagePlayer: View {
}
}
+ private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
+ Image(systemName: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
+ .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
+ .clipShape(Circle())
+ }
+
private func loadingIcon() -> some View {
ProgressView()
- .frame(width: 30, height: 30)
- .frame(width: 56, height: 56)
- .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
+ .frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
+ .frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
+ .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingSource: CryptoFile) {
- chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
+ let audioPath = getAppFilePath(recordingSource.filePath)
+ let chatId = chatModel.chatId
+ let userId = chatModel.currentUser?.userId
+ chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer(
- onTimer: { playbackTime = $0 },
+ onTimer: {
+ playbackTime = $0
+ notifyStateChange()
+ // Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
+ if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
+ stopPlayback()
+ }
+ },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
+ notifyStateChange()
}
)
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
+ notifyStateChange()
}
+
+ private func stopPlayback() {
+ audioPlayer?.stop()
+ playbackState = .noPlayback
+ playbackTime = TimeInterval(0)
+ notifyStateChange()
+ }
+}
+
+@inline(__always)
+func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
+ let squareToCircleRatio = 0.935
+ return squareSize + squareSize * (1 - squareToCircleRatio)
+}
+
+class VoiceItemState {
+ var audioPlayer: AudioPlayer?
+ var playbackState: VoiceMessagePlaybackState
+ var playbackTime: TimeInterval?
+
+ init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
+ self.audioPlayer = audioPlayer
+ self.playbackState = playbackState
+ 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)"
+ }
+
+ static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
+ let id = id(chatInfo, chatItem)
+ if let item = smallView[id] {
+ item.audioPlayer?.stop()
+ ChatModel.shared.stopPreviousRecPlay = nil
+ }
+ }
+
+ static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
+ let id = id(chatInfo, chatItem)
+ if let item = chatView[id] {
+ item.audioPlayer?.stop()
+ ChatModel.shared.stopPreviousRecPlay = nil
+ }
+ }
+
+ static var smallView: [String: VoiceItemState] = [:]
+ static var chatView: [String: VoiceItemState] = [:]
}
struct CIVoiceView_Previews: PreviewProvider {
@@ -289,15 +455,12 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
- audioPlayer: .constant(nil),
- playbackState: .constant(.playing),
- playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ 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/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
index 4763707421..ed2340b6c4 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
@@ -10,22 +10,21 @@ import SwiftUI
import SimpleXChat
struct DeletedItemView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Text(chatItem.content.text)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.italic()
- CIMetaView(chat: chat, chatItem: chatItem)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, colorScheme))
- .cornerRadius(18)
+ .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
index f57e45fed0..250d9d5636 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct EmojiItemView: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@@ -18,7 +19,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 6)
- CIMetaView(chat: chat, chatItem: chatItem)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index af5c917dc8..f4e2a4135a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -12,21 +12,24 @@ import SwiftUI
import SimpleXChat
struct FramedCIVoiceView: View {
+ @EnvironmentObject var theme: AppTheme
+ @ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
-
+
+ @State var audioPlayer: AudioPlayer? = nil
+ @State var playbackState: VoiceMessagePlaybackState = .noPlayback
+ @State var playbackTime: TimeInterval? = nil
+
@Binding var allowMenu: Bool
-
- @Binding var audioPlayer: AudioPlayer?
- @Binding var playbackState: VoiceMessagePlaybackState
- @Binding var playbackTime: TimeInterval?
-
+
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
+ chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -35,14 +38,15 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
- allowMenu: $allowMenu
+ allowMenu: $allowMenu,
+ sizeMultiplier: 1
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
@@ -88,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 3475e7a8b6..b27d266d8a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -9,38 +9,36 @@
import SwiftUI
import SimpleXChat
-let notesChatColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.21)
-let notesChatColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.19)
-let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
-let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
-private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
-private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
-
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
- @Binding var revealed: Bool
+ var scrollToItemId: (ChatItem.ID) -> Void
+ var preview: UIImage?
var maxWidth: CGFloat = .infinity
- @State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
- @State var imgWidth: CGFloat? = nil
- @State var videoWidth: CGFloat? = nil
- @State var metaColor = Color.secondary
+ var imgWidth: CGFloat? = nil
+ var videoWidth: CGFloat? = nil
+ @State private var useWhiteMetaColor: Bool = false
@State var showFullScreenImage = false
@Binding var allowMenu: Bool
- @State private var showSecrets = false
- @State private var showQuoteSecrets = false
-
- @Binding var audioPlayer: AudioPlayer?
- @Binding var playbackState: VoiceMessagePlaybackState
- @Binding var playbackTime: TimeInterval?
+ @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())
@@ -57,40 +55,53 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
- .onTapGesture {
- if let proxy = scrollProxy,
- let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
+ .simultaneousGesture(TapGesture().onEnded {
+ if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
- proxy.scrollTo(ci.viewId, anchor: .bottom)
+ 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())
}
- .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 }
- if chatItem.content.msgContent != nil {
- CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor)
- .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, colorScheme))
- .cornerRadius(18)
+ .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
- switch chatItem.meta.itemStatus {
- case .sndErrorAuth:
- v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") }
- case let .sndError(agentError):
- v.onTapGesture { msgDeliveryError("Unexpected error: \(agentError)") }
- default: v
+ if let (title, text) = chatItem.meta.itemStatus.statusInfo {
+ v.simultaneousGesture(TapGesture().onEnded {
+ AlertManager.shared.showAlert(
+ Alert(
+ title: Text(title),
+ message: Text(text)
+ )
+ )
+ })
+ } else {
+ v
}
}
@@ -107,40 +118,46 @@ struct FramedItemView: View {
.padding(.bottom, 2)
} else {
switch (chatItem.content.msgContent) {
- case let .image(text, image):
- CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
+ case let .image(text, _):
+ CIImageView(chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
- .preference(
- key: MetaColorPreferenceKey.self,
- value: .white
- )
+ .onAppear {
+ useWhiteMetaColor = true
+ }
+ .onDisappear {
+ useWhiteMetaColor = false
+ }
} else {
ciMsgContentView(chatItem)
}
- case let .video(text, image, duration):
- CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
+ case let .video(text, _, duration):
+ CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
- .preference(
- key: MetaColorPreferenceKey.self,
- value: .white
- )
+ .onAppear {
+ useWhiteMetaColor = true
+ }
+ .onDisappear {
+ useWhiteMetaColor = false
+ }
} else {
ciMsgContentView(chatItem)
}
case let .voice(text, duration):
- FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
+ FramedCIVoiceView(chat: chat, chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu)
.overlay(DetermineWidth())
if text != "" {
ciMsgContentView(chatItem)
}
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,33 +172,27 @@ struct FramedItemView: View {
}
}
}
-
- private func msgDeliveryError(_ err: LocalizedStringKey) {
- AlertManager.shared.showAlertMsg(
- title: "Message delivery error",
- message: err
- )
- }
- @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> 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)
.lineLimit(1)
}
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.top, 6)
- .padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
+ .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
- .background(chatItemFrameContextColor(chatItem, colorScheme))
+ .background(chatItemFrameContextColor(chatItem, theme))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@@ -190,11 +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 data = Data(base64Encoded: dropImagePrefix(image)),
- let uiImage = UIImage(data: data) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -206,8 +217,7 @@ struct FramedItemView: View {
ciQuotedMsgView(qi)
}
case let .video(_, image, _):
- if let data = Data(base64Encoded: dropImagePrefix(image)),
- let uiImage = UIImage(data: data) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -233,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, colorScheme))
-
+ .background(backgroundColor)
+ .environment(\.containerBackground, UIColor(backgroundColor))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@@ -246,7 +256,10 @@ struct FramedItemView: View {
Group {
if let sender = qi.getSender(membership()) {
VStack(alignment: .leading, spacing: 2) {
- Text(sender).font(.caption).foregroundColor(.secondary)
+ Text(sender)
+ .font(.caption)
+ .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
+ .lineLimit(1)
ciQuotedMsgTextView(qi, lines: 2)
}
} else {
@@ -257,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 {
@@ -284,24 +295,27 @@ 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)
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
- .textSelection(.enabled)
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
@@ -327,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."
+ )
}
}
@@ -344,51 +357,44 @@ func isRightToLeft(_ s: String) -> Bool {
return false
}
-private struct MetaColorPreferenceKey: PreferenceKey {
- static var defaultValue = Color.secondary
- static func reduce(value: inout Color, nextValue: () -> Color) {
- value = nextValue()
- }
-}
-
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
if case let .image(text, _) = ci.content.msgContent {
- return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
+ return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
} else if case let .video(text, _, _) = ci.content.msgContent {
- return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
+ return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
}
return false
}
-func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
+func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ theme: AppTheme) -> Color {
onlyImageOrVideo(ci)
? Color.clear
- : chatItemFrameColor(ci, colorScheme)
+ : chatItemFrameColor(ci, theme)
}
-func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
+func chatItemFrameColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
ci.chatDir.sent
- ? (colorScheme == .light ? sentColorLight : sentColorDark)
- : Color(uiColor: .tertiarySystemGroupedBackground)
+ ? theme.appColors.sentMessage
+ : theme.appColors.receivedMessage
}
-func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
+func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
ci.chatDir.sent
- ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
- : Color(uiColor: .quaternarySystemFill)
+ ? theme.appColors.sentQuote
+ : theme.appColors.receivedQuote
}
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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ 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))
}
@@ -397,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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ 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))
}
}
@@ -415,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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- 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), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ 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 0e721acdcb..10e5efa298 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
@@ -14,11 +14,11 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
+ var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@Binding var showView: Bool
- @State var scrollProxy: ScrollViewProxy?
@State private var showNext = false
@State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer?
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
- if let proxy = scrollProxy {
- proxy.scrollTo(chatItem.viewId)
- }
+ scrollToItemId?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
@@ -128,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 1aa0093c9a..47a30f6cf3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
var msgError: MsgErrorType
var chatItem: ChatItem
@@ -30,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()
@@ -46,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.")
))
}
@@ -54,6 +55,7 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View {
@ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var onTap: () -> Void
@@ -62,15 +64,14 @@ struct CIMsgError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
- CIMetaView(chat: chat, chatItem: chatItem)
+ CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
- .cornerRadius(18)
+ .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 cb0b61f537..87a9b2ce61 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -11,19 +11,18 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @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(.secondary)
+ .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, colorScheme))
- .cornerRadius(18)
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
}
@@ -36,8 +35,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set = []
- while i < m.reversedChatItems.count,
- let ci = .some(m.reversedChatItems[i]),
+ while i < ItemsModel.shared.reversedChatItems.count,
+ let ci = .some(ItemsModel.shared.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -68,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"
+ }
}
}
}
@@ -80,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 ccd7ac0a12..e04584dfff 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -11,46 +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()
@@ -60,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)
+ @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(.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)
+ (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) -> 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(Color(uiColor: .tertiaryLabel)) + 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 {
@@ -162,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
new file mode 100644
index 0000000000..dfc620c402
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
@@ -0,0 +1,132 @@
+//
+// ChatItemForwardingView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 12.04.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ChatItemForwardingView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+
+ var chatItems: [ChatItem]
+ var fromChatInfo: ChatInfo
+ @Binding var composeState: ComposeState
+
+ @State private var searchText: String = ""
+ @State private var alert: SomeAlert?
+ private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
+
+ var body: some View {
+ NavigationView {
+ forwardListView()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .principal) {
+ Text("Forward")
+ .bold()
+ }
+ }
+ }
+ .modifier(ThemedBackground())
+ .alert(item: $alert) { $0.alert }
+ }
+
+ private func forwardListView() -> some View {
+ VStack(alignment: .leading) {
+ if !chatsToForwardTo.isEmpty {
+ List {
+ let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
+ let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
+ ForEach(chats) { chat in
+ forwardListChatView(chat)
+ .disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
+ }
+ }
+ .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
+ .modifier(ThemedBackground(grouped: true))
+ } else {
+ ZStack {
+ emptyList()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .modifier(ThemedBackground())
+ }
+ }
+ }
+
+ private func emptyList() -> some View {
+ Text("No filtered chats")
+ .foregroundColor(theme.colors.secondary)
+ .frame(maxWidth: .infinity)
+ }
+
+ @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
+ 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(
+ alert: mkAlert(
+ title: "Cannot forward message",
+ message: "Selected chat preferences prohibit this message."
+ ),
+ id: "forward prohibited by preferences"
+ )
+ } else {
+ dismiss()
+ if chat.id == fromChatInfo.id {
+ composeState = ComposeState(
+ message: composeState.message,
+ preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
+ contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
+ )
+ } else {
+ composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
+ ItemsModel.shared.loadOpenChat(chat.id)
+ }
+ }
+ } label: {
+ HStack {
+ ChatInfoImage(chat: chat, size: 30)
+ .padding(.trailing, 2)
+ Text(chat.chatInfo.chatViewName)
+ .foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
+ .lineLimit(1)
+ if chat.chatInfo.incognito {
+ Spacer()
+ Image(systemName: "theatermasks")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 22, height: 22)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+#Preview {
+ ChatItemForwardingView(
+ 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 8dd43cc01b..cd75d1b0cd 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -11,16 +11,21 @@ import SimpleXChat
struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @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
+ @State private var messageStatusLimited: Bool = true
+ @State private var fileStatusLimited: Bool = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum CIInfoTab {
case history
case quote
+ case forwarded
case delivery
}
@@ -68,9 +73,20 @@ struct ChatItemInfoView: View {
if ci.quotedItem != nil {
numTabs += 1
}
+ if chatItemInfo?.forwardedFromChatItem != nil {
+ numTabs += 1
+ }
return numTabs
}
+ private var local: Bool {
+ switch ci.chatDir {
+ case .localSnd: true
+ case .localRcv: true
+ default: false
+ }
+ }
+
@ViewBuilder private func itemInfoView() -> some View {
if numTabs > 1 {
TabView(selection: $selection) {
@@ -86,12 +102,22 @@ struct ChatItemInfoView: View {
Label("History", systemImage: "clock")
}
.tag(CIInfoTab.history)
+ .modifier(ThemedBackground())
if let qi = ci.quotedItem {
quoteTab(qi)
.tabItem {
Label("In reply to", systemImage: "arrowshape.turn.up.left")
}
.tag(CIInfoTab.quote)
+ .modifier(ThemedBackground())
+ }
+ if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
+ forwardedFromTab(forwardedFromItem)
+ .tabItem {
+ Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
+ }
+ .tag(CIInfoTab.forwarded)
+ .modifier(ThemedBackground())
}
}
.onAppear {
@@ -101,12 +127,13 @@ struct ChatItemInfoView: View {
}
} else {
historyTab()
+ .modifier(ThemedBackground())
}
}
- @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()
@@ -137,11 +164,40 @@ struct ChatItemInfoView: View {
if developerTools {
infoRow("Database ID", "\(meta.itemId)")
infoRow("Record updated at", localTimestamp(meta.updatedAt))
+ let msv = infoRow("Message status", ci.meta.itemStatus.id)
+ Group {
+ if messageStatusLimited {
+ msv.lineLimit(1)
+ } else {
+ msv
+ }
+ }
+ .onTapGesture {
+ withAnimation {
+ messageStatusLimited.toggle()
+ }
+ }
+
+ if let file = ci.file {
+ let fsv = infoRow("File status", file.fileStatus.id)
+ Group {
+ if fileStatusLimited {
+ fsv.lineLimit(1)
+ } else {
+ fsv
+ }
+ }
+ .onTapGesture {
+ withAnimation {
+ fileStatusLimited.toggle()
+ }
+ }
+ }
}
}
}
- @ViewBuilder private func historyTab() -> some View {
+ private func historyTab() -> some View {
GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84
ScrollView {
@@ -161,7 +217,7 @@ struct ChatItemInfoView: View {
}
else {
Text("No history")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
}
@@ -171,13 +227,14 @@ 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, colorScheme))
- .cornerRadius(18)
+ .background(backgroundColor)
+ .modifier(ChatItemClipped())
.contextMenu {
if itemVersion.msgContent.text != "" {
Button {
@@ -201,28 +258,33 @@ 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()
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
}
private struct TextBubble: View {
+ @EnvironmentObject var theme: AppTheme
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))
+ 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 {
@@ -240,13 +302,14 @@ 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, colorScheme))
- .cornerRadius(18)
+ .background(quotedMsgFrameColor(qi, theme))
+ .modifier(ChatItemClipped())
.contextMenu {
if qi.text != "" {
Button {
@@ -269,13 +332,82 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
- func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
+ func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
(qi.chatDir?.sent ?? false)
- ? (colorScheme == .light ? sentColorLight : sentColorDark)
- : Color(uiColor: .tertiarySystemGroupedBackground)
+ ? theme.appColors.sentMessage
+ : theme.appColors.receivedMessage
}
- @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ details()
+ Divider().padding(.vertical)
+ Text(local ? "Saved from" : "Forwarded from")
+ .font(.title2)
+ .padding(.bottom, 4)
+ forwardedFromView(forwardedFromItem)
+ }
+ .padding()
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ Task {
+ await MainActor.run {
+ ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
+ dismiss()
+ }
+ }
+ }
+ } label: {
+ forwardedFromSender(forwardedFromItem)
+ }
+
+ if !local {
+ Divider().padding(.top, 32)
+ Text("Recipient(s) can't see who this message is from.")
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+
+ private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
+ HStack {
+ ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
+ .padding(.trailing, 6)
+
+ if forwardedFromItem.chatItem.chatDir.sent {
+ VStack(alignment: .leading) {
+ Text("you")
+ .italic()
+ .foregroundColor(theme.colors.onBackground)
+ Text(forwardedFromItem.chatInfo.chatViewName)
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(1)
+ }
+ } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
+ VStack(alignment: .leading) {
+ Text(groupMember.chatViewName)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ Text(forwardedFromItem.chatInfo.chatViewName)
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(1)
+ }
+ } else {
+ Text(forwardedFromItem.chatInfo.chatViewName)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ }
+ }
+ }
+
+ private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
@@ -290,57 +422,44 @@ struct ChatItemInfoView: View {
.frame(maxHeight: .infinity, alignment: .top)
}
- @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
- VStack(alignment: .leading, spacing: 12) {
+ private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
- memberDeliveryStatusView(memberStatus.0, memberStatus.1)
+ memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2)
}
} else {
Text("No delivery information")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
}
}
- private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
+ private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
- return (mem.wrapped, mds.memberDeliveryStatus)
+ return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
} else {
return nil
}
})
}
- private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
+ private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
- ProfileImage(imageStr: member.image)
- .frame(width: 30, height: 30)
+ MemberProfileImage(member, size: 30)
.padding(.trailing, 2)
Text(member.chatViewName)
.lineLimit(1)
Spacer()
+ if sentViaProxy == true {
+ Image(systemName: "arrow.forward")
+ .foregroundColor(theme.colors.secondary).opacity(0.67)
+ }
let v = Group {
- if let (icon, statusColor) = status.statusIcon(Color.secondary) {
- switch status {
- case .sndRcvd:
- 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)
- }
- } else {
- Image(systemName: "ellipsis")
- .foregroundColor(Color.secondary)
- }
+ let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
+ image.foregroundColor(statusColor)
}
if let (title, text) = status.statusInfo {
@@ -382,8 +501,12 @@ struct ChatItemInfoView: View {
if developerTools {
shareText += [
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
- String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
+ String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)),
+ String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
]
+ if let file = ci.file {
+ shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
+ }
}
if let qi = ci.quotedItem {
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
@@ -433,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 da9dc523e1..f5558bcd93 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -9,50 +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
- @State var scrollProxy: ScrollViewProxy? = nil
- @Binding var revealed: Bool
@Binding var allowMenu: Bool
- @Binding var audioPlayer: AudioPlayer?
- @Binding var playbackState: VoiceMessagePlaybackState
- @Binding var playbackTime: TimeInterval?
+
init(
chat: Chat,
chatItem: ChatItem,
+ scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
- scrollProxy: ScrollViewProxy? = nil,
- revealed: Binding,
- allowMenu: Binding = .constant(false),
- audioPlayer: Binding = .constant(nil),
- playbackState: Binding = .constant(.noPlayback),
- playbackTime: Binding = .constant(nil)
+ allowMenu: Binding = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
+ self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
- _scrollProxy = .init(initialValue: scrollProxy)
- _revealed = revealed
_allowMenu = allowMenu
- _audioPlayer = audioPlayer
- _playbackState = playbackState
- _playbackTime = playbackTime
}
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
- MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
- } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
+ 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, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
+ 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()
}
@@ -62,15 +83,41 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
- FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
+ let preview = chatItem.content.msgContent
+ .flatMap {
+ switch $0 {
+ case let .image(_, image): image
+ case let .video(_, image, _): image
+ default: nil
+ }
+ }
+ .flatMap { imageFromBase64($0) }
+ let adjustedMaxWidth = {
+ if let preview, preview.size.width <= preview.size.height {
+ maxWidth * 0.75
+ } else {
+ maxWidth
+ }
+ }()
+ return FramedItemView(
+ chat: chat,
+ chatItem: chatItem,
+ scrollToItemId: scrollToItemId,
+ preview: preview,
+ maxWidth: maxWidth,
+ imgWidth: adjustedMaxWidth,
+ videoWidth: adjustedMaxWidth,
+ allowMenu: $allowMenu
+ )
}
}
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
@@ -97,14 +144,14 @@ struct ChatItemContentView: View {
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
- case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
- case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
+ case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
+ case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
- CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
- case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
- case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
+ 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)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case .sndModerated: deletedItemView()
@@ -127,29 +174,29 @@ struct ChatItemContentView: View {
}
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
- CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
+ CIGroupInvitationView(chat: chat, chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
}
private func eventItemView() -> some View {
- return CIEventView(eventText: eventItemViewText())
+ CIEventView(eventText: eventItemViewText(theme.colors.secondary))
}
- private func eventItemViewText() -> Text {
+ private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
- return chatEventText(t + Text(" ") + chatItem.timestampText)
+ return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(secondaryColor)
.fontWeight(.light)
- + chatEventText(chatItem)
+ + chatEventText(chatItem, secondaryColor)
} else {
- return chatEventText(chatItem)
+ return chatEventText(chatItem, secondaryColor)
}
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
- CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
+ CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -169,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)
}
@@ -179,7 +226,7 @@ struct ChatItemContentView: View {
info.pqEnabled
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.fontWeight(.light)
: e2eeInfoNoPQText()
}
@@ -187,39 +234,40 @@ struct ChatItemContentView: View {
private func e2eeInfoNoPQText() -> Text {
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.fontWeight(.light)
}
}
-func chatEventText(_ text: Text) -> Text {
+func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
text
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(secondaryColor)
.fontWeight(.light)
}
-func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
- chatEventText(Text(eventText) + Text(" ") + ts)
+func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
+ chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
-func chatEventText(_ ci: ChatItem) -> Text {
- chatEventText("\(ci.content.text)", ci.timestampText)
+func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
+ chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
}
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)
}
@@ -238,7 +286,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -249,7 +297,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -260,7 +308,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -271,7 +319,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- revealed: Binding.constant(true)
+ scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -282,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 cd2aa55bc3..c136ebc01b 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -9,44 +9,63 @@
import SwiftUI
import SimpleXChat
import SwiftyGif
+import Combine
private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @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
@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 tableView: UITableView?
- @State private var loadingItems = false
- @State private var firstPage = false
- @State private var itemsInView: Set = []
- @State private var scrollProxy: ScrollViewProxy?
- @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 membersLoaded = false
@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
var body: some View {
if #available(iOS 16.0, *) {
viewBody
- .scrollDismissesKeyboard(.immediately)
- .keyboardPadding()
+ .scrollDismissesKeyboard(.immediately)
+ .toolbarBackground(.hidden, for: .navigationBar)
} else {
viewBody
}
@@ -54,93 +73,264 @@ struct ChatView: View {
private var viewBody: some View {
let cInfo = chat.chatInfo
- return VStack(spacing: 0) {
- if searchMode {
- searchToolbar()
- Divider()
+ 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)
+ let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base)
+ Color.clear.ignoresSafeArea(.all)
+ .if(wallpaperImage != nil) { view in
+ view.modifier(
+ ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor)
+ )
}
- ZStack(alignment: .trailing) {
- chatItemsList()
- if let proxy = scrollProxy {
- floatingButtons(proxy)
+ VStack(spacing: 0) {
+ ZStack(alignment: .bottomTrailing) {
+ chatItemsList()
+ 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,
+ 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,
+ selectedChatItems: $selectedChatItems,
+ chatInfo: chat.chatInfo,
+ deleteItems: { forAll in
+ allowToDeleteSelectedMessagesForAll = forAll
+ showDeleteSelectedMessages = true
+ },
+ archiveItems: {
+ showArchiveSelectedReports = true
+ },
+ moderateItems: {
+ if case let .group(groupInfo) = chat.chatInfo {
+ showModerateSelectedMessagesAlert(groupInfo)
+ }
+ },
+ forwardItems: forwardSelectedMessages
+ )
}
}
-
- Spacer(minLength: 0)
-
- connectingText()
- ComposeView(
- chat: chat,
- composeState: $composeState,
- keyboardVisible: $keyboardVisible
- )
- .disabled(!cInfo.sendMsgEnabled)
+ if im.showLoadingProgress == chat.id {
+ ProgressView().scaleEffect(2)
+ }
+ }
+ .safeAreaInset(edge: .top) {
+ VStack(spacing: .zero) {
+ if showSearch { searchToolbar() }
+ Divider()
+ }
+ .background(ToolbarMaterial.material(toolbarMaterial))
}
- .padding(.top, 1)
.navigationTitle(cInfo.chatViewName)
+ .background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
+ .environmentObject(theme)
+ .confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) {
+ Button("Delete for me", role: .destructive) {
+ if let selected = selectedChatItems {
+ deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) }
+ }
+ if allowToDeleteSelectedMessagesForAll {
+ Button(broadcastDeleteButtonText(chat), role: .destructive) {
+ if let selected = selectedChatItems {
+ allowToDeleteSelectedMessagesForAll = false
+ deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages)
+ }
+ }
+ }
+ }
+ .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
- if cId != nil {
+ showChatInfoSheet = false
+ selectedChatItems = nil
+ 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()
}
}
- .onDisappear {
- VideoPlayerView.players.removeAll()
- if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
- chatModel.chatId = nil
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
- if chatModel.chatId == nil {
- chatModel.chatItemStatuses = [:]
- chatModel.reversedChatItems = []
- chatModel.groupMembers = []
- membersLoaded = false
+ .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
}
}
}
}
+ .onDisappear {
+ VideoPlayerView.players.removeAll()
+ stopAudioPlayer()
+ if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ if chatModel.chatId == nil {
+ chatModel.chatItemStatuses = [:]
+ ItemsModel.shared.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
+ chatModel.groupMembers = []
+ chatModel.groupMembersIndexes.removeAll()
+ chatModel.membersLoaded = false
+ }
+ }
+ }
+ }
+ .onChange(of: colorScheme) { _ in
+ theme = buildTheme()
+ }
.toolbar {
ToolbarItem(placement: .principal) {
- if case let .direct(contact) = cInfo {
+ if selectedChatItems != nil {
+ SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
+ } else if case let .direct(contact) = cInfo {
Button {
Task {
- do {
- let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
- let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
- await MainActor.run {
- connectionStats = stats
- customUserProfile = profile
- connectionCode = code
- if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
- chat.chatInfo = .direct(contact: ct)
- }
- }
- } catch let error {
- logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
- }
- await MainActor.run { showChatInfoSheet = true }
+ showChatInfoSheet = true
}
} label: {
ChatInfoToolbar(chat: chat)
}
- .sheet(isPresented: $showChatInfoSheet, onDismiss: {
- connectionStats = nil
- customUserProfile = nil
- connectionCode = nil
- }) {
- ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
+ .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
+ ChatInfoView(
+ chat: chat,
+ contact: contact,
+ localAlias: chat.chatInfo.localAlias,
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ onSearch: { focusSearch() }
+ )
}
} else if case let .group(groupInfo) = cInfo {
Button {
- Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
+ Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
} label: {
ChatInfoToolbar(chat: chat)
+ .tint(theme.colors.primary)
}
- .appSheet(isPresented: $showChatInfoSheet) {
+ .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
GroupChatInfoView(
chat: chat,
groupInfo: Binding(
@@ -149,7 +339,9 @@ struct ChatView: View {
chat.chatInfo = .group(groupInfo: gInfo)
chat.created = Date.now
}
- )
+ ),
+ onSearch: { focusSearch() },
+ localAlias: groupInfo.localAlias
)
}
} else if case .local = cInfo {
@@ -157,82 +349,78 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
- switch cInfo {
- case let .direct(contact):
- HStack {
- let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
- if callsPrefEnabled {
- if chatModel.activeCall == nil {
- callButton(contact, .audio, imageName: "phone")
- .disabled(!contact.ready || !contact.active)
- } else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
- endCallButton(call)
- }
+ if selectedChatItems != nil {
+ Button {
+ withAnimation {
+ selectedChatItems = nil
}
- Menu {
- if callsPrefEnabled && chatModel.activeCall == nil {
- Button {
- CallController.shared.startCall(contact, .video)
- } label: {
- Label("Video call", systemImage: "video")
+ } label: {
+ Text("Cancel")
+ }
+ } else {
+ switch cInfo {
+ case let .direct(contact):
+ HStack {
+ let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
+ if callsPrefEnabled {
+ if chatModel.activeCall == nil {
+ callButton(contact, .audio, imageName: "phone")
+ .disabled(!contact.ready || !contact.active)
+ } else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
+ endCallButton(call)
}
- .disabled(!contact.ready || !contact.active)
}
- searchButton()
- toggleNtfsButton(chat)
- .disabled(!contact.ready || !contact.active)
- } label: {
- Image(systemName: "ellipsis")
- }
- }
- case let .group(groupInfo):
- HStack {
- if groupInfo.canAddMembers {
- if (chat.chatInfo.incognito) {
- groupLinkButton()
- .appSheet(isPresented: $showGroupLinkSheet) {
- GroupLinkView(
- groupId: groupInfo.groupId,
- groupLink: $groupLink,
- groupLinkMemberRole: $groupLinkMemberRole,
- showTitle: true,
- creatingGroup: false
- )
- }
- } else {
- addMembersButton()
- .appSheet(isPresented: $showAddMembersSheet) {
- AddGroupMembersView(chat: chat, groupInfo: groupInfo)
+ Menu {
+ if callsPrefEnabled && chatModel.activeCall == nil {
+ Button {
+ CallController.shared.startCall(contact, .video)
+ } label: {
+ Label("Video call", systemImage: "video")
}
+ .disabled(!contact.ready || !contact.active)
+ }
+ searchButton()
+ ToggleNtfsButton(chat: chat)
+ .disabled(!contact.ready || !contact.active)
+ } label: {
+ Image(systemName: "ellipsis")
}
}
- Menu {
- searchButton()
- toggleNtfsButton(chat)
- } label: {
- Image(systemName: "ellipsis")
+ case let .group(groupInfo):
+ HStack {
+ if groupInfo.canAddMembers {
+ if (chat.chatInfo.incognito) {
+ groupLinkButton()
+ .appSheet(isPresented: $showGroupLinkSheet) {
+ GroupLinkView(
+ groupId: groupInfo.groupId,
+ groupLink: $groupLink,
+ groupLinkMemberRole: $groupLinkMemberRole,
+ showTitle: true,
+ creatingGroup: false
+ )
+ }
+ } else {
+ addMembersButton()
+ }
+ }
+ Menu {
+ searchButton()
+ ToggleNtfsButton(chat: chat)
+ } label: {
+ Image(systemName: "ellipsis")
+ }
}
+ case .local:
+ searchButton()
+ default:
+ EmptyView()
}
- case .local:
- searchButton()
- default:
- EmptyView()
}
}
}
}
- private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
- let groupMembers = await apiListMembers(groupInfo.groupId)
- await MainActor.run {
- if chatModel.chatId == groupInfo.id {
- chatModel.groupMembers = groupMembers.map { GMember.init($0) }
- membersLoaded = true
- updateView()
- }
- }
- }
-
private func initChatView() {
let cInfo = chat.chatInfo
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
@@ -251,7 +439,8 @@ struct ChatView: View {
}
}
}
- if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
+ if chatModel.draftChatId == cInfo.id && !composeState.forwarding,
+ let draft = chatModel.draft {
composeState = draft
}
if chat.chatStats.unreadChat {
@@ -259,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 {
@@ -267,7 +490,7 @@ struct ChatView: View {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
- .foregroundColor(.primary)
+ .foregroundColor(theme.colors.onBackground)
.frame(maxWidth: .infinity)
Button {
@@ -277,151 +500,399 @@ struct ChatView: View {
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
Button ("Cancel") {
- searchText = ""
- searchMode = false
- searchFocussed = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
- chatModel.reversedChatItems = []
- 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.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
+ }
+
+ private func filtered(_ reversedChatItems: Array) -> Array {
+ reversedChatItems
+ .enumerated()
+ .filter { (index, chatItem) in
+ if let mergeCategory = chatItem.mergeCategory, index > 0 {
+ mergeCategory != reversedChatItems[index - 1].mergeCategory
+ } else {
+ true
+ }
+ }
+ .map { $0.element }
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(spacing: 0) {
- ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
- 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
- chatItemView(ci, maxWidth)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- .onAppear {
- itemsInView.insert(ci.viewId)
- loadChatItems(cInfo, ci, proxy)
- if ci.isRcvNew {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
- if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
- Task {
- await apiMarkChatItemRead(cInfo, ci)
- }
- }
- }
- }
- }
- .onDisappear {
- itemsInView.remove(ci.viewId)
- }
- }
- }
+ //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
}
- .onAppear {
- scrollProxy = proxy
+ 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
+ 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,
+ showChatInfoSheet: $showChatInfoSheet,
+ revealedItems: $revealedItems,
+ selectedChatItems: $selectedChatItems,
+ forwardedChatItems: $forwardedChatItems,
+ searchText: $searchText,
+ closeKeyboardAndRun: closeKeyboardAndRun
+ )
+ // 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
+ }
+ .onAppear {
+ if !im.isLoading {
+ updateWithInitiallyLoadedItems()
}
- .onTapGesture { hideKeyboard() }
- .onChange(of: searchText) { _ in
- loadChat(chat: chat, search: searchText)
+ }
+ .onChange(of: im.isLoading) { loading in
+ if !loading {
+ updateWithInitiallyLoadedItems()
}
- .onChange(of: chatModel.chatId) { _ in
- if let chatId = chatModel.chatId, let c = chatModel.getChat(chatId) {
- chat = c
- showChatInfoSheet = false
- loadChat(chat: c)
- DispatchQueue.main.async {
- scrollToBottom(proxy)
- }
+ }
+ .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 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)
+ }
+ }
}
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
@ViewBuilder private func connectingText() -> some View {
if case let .direct(contact) = chat.chatInfo,
- !contact.ready,
+ !contact.sndReady,
contact.active,
!contact.nextSendGrpInv {
Text("connecting…")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.padding(.top)
} else {
EmptyView()
}
}
-
- private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
- let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
- return VStack {
- let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
- if unreadAbove > 0 {
- circleButton {
- unreadCountText(unreadAbove)
- .font(.callout)
- .foregroundColor(.accentColor)
+
+ 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 {
+ @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
+
+ 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
}
- .onTapGesture { scrollUp(proxy) }
- .contextMenu {
- Button {
- if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
- Task {
- await markChatRead(chat, aboveItem: ci)
+
+ // 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 resetDate() {
+ date = nil
+ isDateVisible = false
+ }
+
+ 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 }
+ }
+ }
+
+ }
+
+ 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)
+ }
+ 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(.accentColor)
+ .onChange(of: loadingTopItems) { loading in
+ if !loading && requestedTopScroll {
+ requestedTopScroll = false
+ scrollToTopUnread()
}
- .onTapGesture { scrollToBottom(proxy) }
- } else if counts.totalBelow > 16 {
- circleButton {
- Image(systemName: "chevron.down")
- .foregroundColor(.accentColor)
+ }
+ .onChange(of: loadingBottomItems) { loading in
+ if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
+ requestedBottomScroll = false
+ scrollToBottom()
+ }
+ }
+ .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 { scrollToBottom(proxy) }
}
}
- .padding()
- }
-
- private func circleButton(_ content: @escaping () -> Content) -> some View {
- ZStack {
- Circle()
- .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
- .frame(width: 44, height: 44)
- content()
+
+ 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 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)
+ }
+ }
+
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button {
CallController.shared.startCall(contact, media)
@@ -432,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) {}
}
@@ -444,18 +915,40 @@ struct ChatView: View {
private func searchButton() -> some View {
Button {
- searchMode = true
- searchFocussed = true
- searchText = ""
+ focusSearch()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
}
-
+
+ private func focusSearch() {
+ 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 {
- Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
+ Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
Image(systemName: "person.crop.circle.badge.plus")
@@ -481,148 +974,555 @@ struct ChatView: View {
}
}
- private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
- if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
- if loadingItems || firstPage { return }
- loadingItems = true
- Task {
- do {
- let items = try await apiGetChatItems(
- type: cInfo.chatType,
- id: cInfo.apiId,
- pagination: .before(chatItemId: firstItem.id, count: 50),
- search: searchText
- )
- await MainActor.run {
- if items.count == 0 {
- firstPage = true
- } else {
- chatModel.reversedChatItems.append(contentsOf: items.reversed())
- }
- loadingItems = false
- }
- } catch let error {
- logger.error("apiGetChat error: \(responseError(error))")
- await MainActor.run { loadingItems = false }
+ private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) {
+ guard let count = selectedChatItems?.count, count > 0 else { return }
+
+ AlertManager.shared.showAlert(Alert(
+ title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"),
+ message: Text(
+ groupInfo.fullGroupPreferences.fullDelete.on
+ ? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.")
+ : (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.")
+ ),
+ primaryButton: .destructive(Text("Delete")) {
+ if let selected = selectedChatItems {
+ deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages)
}
+ },
+ secondaryButton: .cancel()
+ ))
+ }
+
+ private func deletedSelectedMessages() async {
+ await MainActor.run {
+ withAnimation {
+ selectedChatItems = nil
}
}
}
-
- @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
- ChatItemWithMenu(
- chat: chat,
- chatItem: ci,
- maxWidth: maxWidth,
- itemWidth: maxWidth,
- composeState: $composeState,
- selectedMember: $selectedMember,
- chatView: self
+
+ private func forwardSelectedMessages() {
+ Task {
+ do {
+ if let selectedChatItems {
+ let (validItems, confirmation) = try await apiPlanForwardChatItems(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemIds: Array(selectedChatItems)
+ )
+ 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 {
+ await openForwardingSheet(validItems)
+ }
+ }
+ } 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() {
+ VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() }
+ 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
- @Environment(\.colorScheme) var colorScheme
- @ObservedObject var chat: Chat
- var chatItem: ChatItem
- var maxWidth: CGFloat
- @State var itemWidth: CGFloat
+ @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?
- var chatView: ChatView
+ @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 revealed = 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 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
- @State private var audioPlayer: AudioPlayer?
- @State private var playbackState: VoiceMessagePlaybackState = .noPlayback
- @State private var playbackTime: TimeInterval?
+ 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, nextItem) = m.getNextChatItem(chatItem)
- let ciCategory = chatItem.mergeCategory
- if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) {
- // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
- ZStack {} // scroll doesn't work if it's EmptyView()
+ let im = ItemsModel.shared
+
+ 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 {
- let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
- let range = itemsRange(currIndex, prevHidden)
- if revealed, let range = range {
- let items = Array(zip(Array(range), m.reversedChatItems[range]))
- ForEach(items, id: \.1.viewId) { (i, ci) in
- let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
- chatItemView(ci, nil, prev)
+ 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())
+ .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 {
- // Switch branches just to work around context menu problem when 'revealed' changes but size of item isn't
- if revealed {
- chatItemView(chatItem, range, prevItem)
- } else {
- chatItemView(chatItem, range, prevItem)
+ markedRead = true
+ }
+ if let range {
+ let (itemIds, unreadMentions) = unreadItemIds(range)
+ if !itemIds.isEmpty {
+ waitToMarkRead {
+ await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
+ }
}
+ } else if chatItem.isRcvNew {
+ waitToMarkRead {
+ 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 unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) {
+ let im = ItemsModel.shared
+ 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 (unreadItems, unreadMentions)
+ }
+
+ private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
+ 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 {
- Text(memberNames(member, prevMember, memCount))
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.leading, memberImageSize + 14)
- .padding(.top, 7)
- }
- HStack(alignment: .top, spacing: 8) {
- ProfileImage(imageStr: member.memberProfile.image)
- .frame(width: memberImageSize, height: memberImageSize)
- .onTapGesture {
- if chatView.membersLoaded {
- selectedMember = m.getGroupMember(member.groupMemberId)
- } else {
- Task {
- await chatView.loadGroupMembers(groupInfo) {
- selectedMember = m.getGroupMember(member.groupMemberId)
+ 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)
}
- .appSheet(item: $selectedMember) { member in
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
- }
- chatItemWithMenu(ci, range, maxWidth)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
+ .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: 10) {
+ MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
+ .simultaneousGesture(TapGesture().onEnded {
+ if let mem = m.getGroupMember(member.groupMemberId) {
+ selectedMember = mem
+ } else {
+ let mem = GMember.init(member)
+ m.groupMembers.append(mem)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = mem
+ }
+ })
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
+ .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
+ }
}
}
- .padding(.top, 5)
+ .padding(.bottom, bottomPadding)
.padding(.trailing)
.padding(.leading, 12)
} else {
- chatItemWithMenu(ci, range, maxWidth)
- .padding(.top, 5)
- .padding(.trailing)
- .padding(.leading, memberImageSize + 8 + 12)
+ HStack(alignment: .center, spacing: 0) {
+ if selectedChatItems != nil && ci.canBeDeletedForSelf {
+ SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
+ .padding(.leading, 12)
+ }
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
+ .padding(.trailing)
+ .padding(.leading, 10 + memberImageSize + 12)
+ }
+ .padding(.bottom, bottomPadding)
}
} else {
- chatItemWithMenu(ci, range, maxWidth)
- .padding(.horizontal)
- .padding(.top, 5)
+ HStack(alignment: .center, spacing: 0) {
+ if selectedChatItems != nil && ci.canBeDeletedForSelf {
+ if chat.chatInfo.chatType == .group {
+ SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
+ .padding(.leading, 12)
+ } else {
+ SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
+ .padding(.leading)
+ }
+ }
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
+ .padding(.horizontal)
+ }
+ .padding(.bottom, bottomPadding)
}
}
@@ -637,27 +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
- let uiMenu: Binding = Binding(
- get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) },
- set: { _ in }
- )
-
- VStack(alignment: alignment.horizontal, spacing: 3) {
- ChatItemView(
- chat: chat,
- chatItem: ci,
- maxWidth: maxWidth,
- scrollProxy: chatView.scrollProxy,
- revealed: $revealed,
- allowMenu: $allowMenu,
- audioPlayer: $audioPlayer,
- playbackState: $playbackState,
- playbackTime: $playbackTime
- )
- .uiKitContextMenu(hasImageOrVideo: ci.content.msgContent?.isImageOrVideo == true, maxWidth: maxWidth, itemWidth: $itemWidth, menu: uiMenu, allowMenu: $allowMenu)
- .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)
@@ -665,33 +1567,41 @@ struct ChatView: View {
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
- deleteMessage(.cidmInternal)
+ deleteMessage(.cidmInternal, moderate: false)
}
- if let di = deletingItem, di.meta.editable && !di.localNote {
- Button(broadcastDeleteButtonText, role: .destructive) {
- deleteMessage(.cidmBroadcast)
+ if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport {
+ Button(broadcastDeleteButtonText(chat), role: .destructive) {
+ deleteMessage(.cidmBroadcast, moderate: false)
}
}
}
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
- deleteMessages()
+ 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)
- .onDisappear {
- if ci.content.msgContent?.isVoice == true {
- allowMenu = true
- audioPlayer?.stop()
- playbackState = .noPlayback
- playbackTime = TimeInterval(0)
- }
- }
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
- ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
+ ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo)
}
}
@@ -715,147 +1625,201 @@ struct ChatView: View {
Text("\(r.totalReacted)")
.font(.caption)
.fontWeight(r.userReacted ? .bold : .light)
- .foregroundColor(r.userReacted ? .accentColor : .secondary)
+ .foregroundColor(r.userReacted ? theme.colors.primary : theme.colors.secondary)
}
}
.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
}
}
}
}
- private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> [UIMenuElement] {
- var menu: [UIMenuElement] = []
- if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
- let rs = allReactions(ci)
+ @ViewBuilder
+ private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View {
+ 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,
- rs.count > 0 {
- var rm: UIMenu
- if #available(iOS 16, *) {
- var children: [UIMenuElement] = Array(rs.prefix(topReactionsCount(rs)))
- if let sm = reactionUIMenu(rs) {
- children.append(sm)
- }
- rm = UIMenu(title: "", options: .displayInline, children: children)
- rm.preferredElementSize = .small
- } else {
- rm = reactionUIMenuPreiOS16(rs)
- }
- menu.append(rm)
+ availableReactions.count > 0 {
+ reactionsGroup
}
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote {
- menu.append(replyUIAction(ci))
+ replyButton
}
let fileSource = getLoadedFileSource(ci.file)
let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false }
let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists)
if copyAndShareAllowed {
- menu.append(shareUIAction(ci))
- menu.append(copyUIAction(ci))
+ shareButton(ci)
+ copyButton(ci)
}
if let fileSource = fileSource, fileExists {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil {
- menu.append(saveFileAction(fileSource))
+ saveButton(file: fileSource)
} else {
- menu.append(saveImageAction(image))
+ saveButton(image: image)
}
} else {
- menu.append(saveFileAction(fileSource))
+ saveButton(file: fileSource)
}
+ } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) {
+ downloadButton(file: file)
}
if ci.meta.editable && !mc.isVoice && !live {
- menu.append(editAction(ci))
+ editButton(chatItem)
+ }
+ if ci.meta.itemDeleted == nil
+ && (ci.file == nil || (fileSource != nil && fileExists))
+ && !ci.isLiveDummy && !live {
+ forwardButton
}
if !ci.isLiveDummy {
- menu.append(viewInfoUIAction(ci))
+ viewInfoButton(ci)
}
if revealed {
- menu.append(hideUIAction())
+ hideButton()
}
if ci.meta.itemDeleted == nil && !ci.localNote,
let file = ci.file,
let cancelAction = file.cancelAction {
- menu.append(cancelFileUIAction(file.fileId, cancelAction))
+ cancelFileButton(file.fileId, cancelAction)
}
if !live || !ci.meta.isLive {
- menu.append(deleteUIAction(ci))
+ deleteButton(ci)
}
- if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
- menu.append(moderateUIAction(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 {
- menu.append(hideUIAction())
+ hideButton()
} else if !ci.isDeletedContent {
- menu.append(revealUIAction())
+ revealButton(ci)
} else if range != nil {
- menu.append(expandUIAction())
+ expandButton()
}
- menu.append(viewInfoUIAction(ci))
- menu.append(deleteUIAction(ci))
+ viewInfoButton(ci)
+ deleteButton(ci)
} else if ci.isDeletedContent {
- menu.append(viewInfoUIAction(ci))
- menu.append(deleteUIAction(ci))
+ viewInfoButton(ci)
+ deleteButton(ci)
} else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) {
- menu.append(revealed ? shrinkUIAction() : expandUIAction())
+ if revealed { shrinkButton() } else { expandButton() }
+ deleteButton(ci)
+ } else if ci.showLocalDelete {
+ deleteButton(ci)
+ } else {
+ EmptyView()
+ }
+ if selectedChatItems == nil && ci.canBeDeletedForSelf {
+ Divider()
+ selectButton(ci)
}
- return menu
}
-
- private func replyUIAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Reply", comment: "chat item action"),
- image: UIImage(systemName: "arrowshape.turn.up.left")
- ) { _ in
+
+ var replyButton: Button {
+ Button {
withAnimation {
if composeState.editing {
- composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
+ composeState = ComposeState(contextItem: .quotedItem(chatItem: chatItem))
} else {
- composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
+ composeState = composeState.copy(contextItem: .quotedItem(chatItem: chatItem))
}
}
+ } label: {
+ Label(
+ NSLocalizedString("Reply", comment: "chat item action"),
+ systemImage: "arrowshape.turn.up.left"
+ )
+ }
+ }
+
+ var forwardButton: Button {
+ Button {
+ forwardedChatItems = [chatItem]
+ } label: {
+ Label(
+ NSLocalizedString("Forward", comment: "chat item action"),
+ systemImage: "arrowshape.turn.up.forward"
+ )
+ }
+ }
+
+ private var reactionsGroup: some View {
+ if #available(iOS 16.4, *) {
+ return ControlGroup {
+ if availableReactions.count > 4 {
+ reactions(till: 3)
+ Menu {
+ reactions(from: 3)
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ } else { reactions() }
+ }.controlGroupStyle(.compactMenu)
+ } else {
+ return Menu {
+ reactions()
+ } label: {
+ Label(
+ NSLocalizedString("React…", comment: "chat item menu"),
+ systemImage: "face.smiling"
+ )
+ }
}
}
- private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu {
- UIMenu(
- title: NSLocalizedString("React…", comment: "chat item menu"),
- image: UIImage(systemName: "face.smiling"),
- children: rs
- )
- }
-
- @available(iOS 16.0, *)
- private func reactionUIMenu(_ rs: [UIAction]) -> UIMenu? {
- var children = rs
- children.removeFirst(min(rs.count, topReactionsCount(rs)))
- if children.count == 0 { return nil }
- return UIMenu(
- title: "",
- image: UIImage(systemName: "ellipsis"),
- children: children
- )
- }
-
- private func allReactions(_ ci: ChatItem) -> [UIAction] {
- MsgReaction.values.compactMap { r in
- ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
- ? nil
- : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) }
+ func reactions(from: Int? = nil, till: Int? = nil) -> some View {
+ ForEach(availableReactions[(from ?? 0)..<(till ?? availableReactions.count)]) { reaction in
+ Button(reaction.text) {
+ setReaction(chatItem, add: true, reaction: reaction)
+ }
}
}
- private func topReactionsCount(_ rs: [UIAction]) -> Int {
- rs.count > 4 ? 3 : 4
+ /// Reactions, which has not been used yet
+ private var availableReactions: Array {
+ MsgReaction.values
+ .filter { reaction in
+ !chatItem.reactions.contains {
+ $0.userReacted && $0.reaction == reaction
+ }
+ }
}
private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) {
@@ -878,24 +1842,23 @@ struct ChatView: View {
}
}
- private func shareUIAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Share", comment: "chat item action"),
- image: UIImage(systemName: "square.and.arrow.up")
- ) { _ in
+ private func shareButton(_ ci: ChatItem) -> Button {
+ Button {
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
+ } label: {
+ Label(
+ NSLocalizedString("Share", comment: "chat item action"),
+ systemImage: "square.and.arrow.up"
+ )
}
}
-
- private func copyUIAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Copy", comment: "chat item action"),
- image: UIImage(systemName: "doc.on.doc")
- ) { _ in
+
+ private func copyButton(_ ci: ChatItem) -> Button {
+ Button {
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
@@ -903,43 +1866,79 @@ struct ChatView: View {
} else {
UIPasteboard.general.string = ci.content.text
}
- }
- }
-
- private func saveImageAction(_ image: UIImage) -> UIAction {
- UIAction(
- title: NSLocalizedString("Save", comment: "chat item action"),
- image: UIImage(systemName: "square.and.arrow.down")
- ) { _ in
- UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
- }
- }
-
- private func saveFileAction(_ fileSource: CryptoFile) -> UIAction {
- UIAction(
- title: NSLocalizedString("Save", comment: "chat item action"),
- image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open")
- ) { _ in
- saveCryptoFile(fileSource)
- }
- }
-
- private func editAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Edit", comment: "chat item action"),
- image: UIImage(systemName: "square.and.pencil")
- ) { _ in
- withAnimation {
- composeState = ComposeState(editingItem: ci)
- }
+ } label: {
+ Label("Copy", systemImage: "doc.on.doc")
}
}
- private func viewInfoUIAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Info", comment: "chat item action"),
- image: UIImage(systemName: "info.circle")
- ) { _ in
+ func saveButton(image: UIImage) -> Button {
+ Button {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
+ } label: {
+ Label(
+ NSLocalizedString("Save", comment: "chat item action"),
+ systemImage: "square.and.arrow.down"
+ )
+ }
+ }
+
+ func saveButton(file: CryptoFile) -> Button {
+ Button {
+ saveCryptoFile(file)
+ } label: {
+ Label(
+ NSLocalizedString("Save", comment: "chat item action"),
+ systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open"
+ )
+ }
+ }
+
+ func downloadButton(file: CIFile) -> Button {
+ Button {
+ Task {
+ logger.debug("ChatView downloadFileAction, in Task")
+ if let user = m.currentUser {
+ await receiveFile(user: user, fileId: file.fileId)
+ }
+ }
+ } label: {
+ Label(
+ NSLocalizedString("Download", comment: "chat item action"),
+ systemImage: "arrow.down.doc"
+ )
+ }
+ }
+
+ private func editButton(_ ci: ChatItem) -> Button {
+ Button {
+ withAnimation {
+ composeState = ComposeState(editingItem: ci)
+ }
+ } label: {
+ Label(
+ NSLocalizedString("Edit", comment: "chat item action"),
+ systemImage: "square.and.pencil"
+ )
+ }
+ }
+
+ private func selectButton(_ ci: ChatItem) -> Button {
+ Button {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ withAnimation {
+ selectUnselectChatItem(select: true, ci)
+ }
+ }
+ } label: {
+ Label(
+ NSLocalizedString("Select", comment: "chat item action"),
+ systemImage: "checkmark.circle"
+ )
+ }
+ }
+
+ private func viewInfoButton(_ ci: ChatItem) -> Button {
+ Button {
Task {
do {
let cInfo = chat.chatInfo
@@ -948,22 +1947,23 @@ struct ChatView: View {
chatItemInfo = ciInfo
}
if case let .group(gInfo) = chat.chatInfo {
- await chatView.loadGroupMembers(gInfo)
+ await m.loadGroupMembers(gInfo)
}
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
}
await MainActor.run { showChatItemInfoSheet = true }
}
+ } label: {
+ Label(
+ NSLocalizedString("Info", comment: "chat item action"),
+ systemImage: "info.circle"
+ )
}
}
- private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction {
- return UIAction(
- title: cancelAction.uiAction,
- image: UIImage(systemName: "xmark"),
- attributes: [.destructive]
- ) { _ in
+ private func cancelFileButton(_ fileId: Int64, _ cancelAction: CancelAction) -> Button {
+ Button {
AlertManager.shared.showAlert(Alert(
title: Text(cancelAction.alert.title),
message: Text(cancelAction.alert.message),
@@ -976,34 +1976,37 @@ struct ChatView: View {
},
secondaryButton: .cancel()
))
+ } label: {
+ Label(
+ cancelAction.uiAction,
+ systemImage: "xmark"
+ )
}
}
- private func hideUIAction() -> UIAction {
- UIAction(
- title: NSLocalizedString("Hide", comment: "chat item action"),
- image: UIImage(systemName: "eye.slash")
- ) { _ in
- withAnimation {
- revealed = false
+ private func hideButton() -> Button {
+ Button {
+ withConditionalAnimation {
+ reveal(false)
}
+ } label: {
+ Label(
+ NSLocalizedString("Hide", comment: "chat item action"),
+ systemImage: "eye.slash"
+ )
}
}
-
- private func deleteUIAction(_ ci: ChatItem) -> UIAction {
- UIAction(
- title: NSLocalizedString("Delete", comment: "chat item action"),
- image: UIImage(systemName: "trash"),
- attributes: [.destructive]
- ) { _ in
- if !revealed && ci.meta.itemDeleted != nil,
+
+ private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button {
+ Button(role: .destructive) {
+ if !revealed,
let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
- itemIds.append(m.reversedChatItems[i].id)
+ itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -1015,6 +2018,8 @@ struct ChatView: View {
showDeleteMessage = true
deletingItem = ci
}
+ } label: {
+ Label(label, systemImage: "trash")
}
}
@@ -1028,63 +2033,106 @@ struct ChatView: View {
}
}
- private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
- UIAction(
- title: NSLocalizedString("Moderate", comment: "chat item action"),
- image: UIImage(systemName: "flag"),
- attributes: [.destructive]
- ) { _ in
+ private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button {
+ Button(role: .destructive) {
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)
+ deleteMessage(.cidmBroadcast, moderate: true)
},
secondaryButton: .cancel()
))
+ } label: {
+ Label(
+ NSLocalizedString("Moderate", comment: "chat item action"),
+ systemImage: "flag"
+ )
}
}
- private func revealUIAction() -> UIAction {
- UIAction(
- title: NSLocalizedString("Reveal", comment: "chat item action"),
- image: UIImage(systemName: "eye")
- ) { _ in
- withAnimation {
- revealed = true
+ 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 {
+ reveal(true)
}
+ } label: {
+ Label(
+ NSLocalizedString("Reveal", comment: "chat item action"),
+ systemImage: "eye"
+ )
}
}
- private func expandUIAction() -> UIAction {
- UIAction(
- title: NSLocalizedString("Expand", comment: "chat item action"),
- image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down")
- ) { _ in
- withAnimation {
- revealed = true
+ private func expandButton() -> Button {
+ Button {
+ withConditionalAnimation {
+ reveal(true)
}
+ } label: {
+ Label(
+ NSLocalizedString("Expand", comment: "chat item action"),
+ systemImage: "arrow.up.and.line.horizontal.and.arrow.down"
+ )
}
}
- private func shrinkUIAction() -> UIAction {
- UIAction(
- title: NSLocalizedString("Hide", comment: "chat item action"),
- image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up")
- ) { _ in
- withAnimation {
- revealed = false
+ private func shrinkButton() -> Button {
+ Button {
+ withConditionalAnimation {
+ reveal(false)
}
+ } label: {
+ Label (
+ NSLocalizedString("Hide", comment: "chat item action"),
+ systemImage: "arrow.down.and.line.horizontal.and.arrow.up"
+ )
}
}
- private var broadcastDeleteButtonText: LocalizedStringKey {
- chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
+ 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 {
@@ -1092,101 +2140,351 @@ struct ChatView: View {
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
}
- private func deleteMessages() {
- let itemIds = deletingItems
- if itemIds.count > 0 {
- let chatInfo = chat.chatInfo
- Task {
- var deletedItems: [ChatItem] = []
- for itemId in itemIds {
- do {
- let (di, _) = try await apiDeleteChatItem(
- type: chatInfo.chatType,
- id: chatInfo.apiId,
- itemId: itemId,
- mode: .cidmInternal
- )
- deletedItems.append(di)
- } catch {
- logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
- }
- }
- await MainActor.run {
- for di in deletedItems {
- m.removeChatItem(chatInfo, di)
- }
+ private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) {
+ selectedChatItems = selectedChatItems ?? []
+ var itemIds: [Int64] = []
+ if !revealed,
+ let currIndex = m.getChatItemIndex(ci),
+ let ciCategory = ci.mergeCategory {
+ let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
+ if let range = itemsRange(currIndex, prevHidden) {
+ for i in range {
+ itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
}
+ } else {
+ itemIds.append(ci.id)
}
+ } else {
+ itemIds.append(ci.id)
+ }
+ if select {
+ if let sel = selectedChatItems {
+ selectedChatItems = sel.union(itemIds)
+ }
+ } else {
+ itemIds.forEach { selectedChatItems?.remove($0) }
}
}
- private func deleteMessage(_ mode: CIDeleteMode) {
+ private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
- var deletedItem: ChatItem
- var toItem: ChatItem?
- if case .cidmBroadcast = mode,
- let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
- (deletedItem, toItem) = try await apiDeleteMemberChatItem(
+ let r = if case .cidmBroadcast = mode,
+ moderate,
+ let (groupInfo, _) = di.memberToModerate(chat.chatInfo) {
+ try await apiDeleteMemberChatItems(
groupId: groupInfo.apiId,
- groupMemberId: groupMember.groupMemberId,
- itemId: di.id
+ itemIds: [di.id]
)
} else {
- (deletedItem, toItem) = try await apiDeleteChatItem(
+ try await apiDeleteChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
- itemId: di.id,
+ itemIds: [di.id],
mode: mode
)
}
- DispatchQueue.main.async {
- deletingItem = nil
- if let toItem = toItem {
- _ = m.upsertChatItem(chat.chatInfo, toItem)
- } else {
- m.removeChatItem(chat.chatInfo, deletedItem)
+ if let itemDeletion = r.first {
+ await MainActor.run {
+ deletingItem = nil
+ if let toItem = itemDeletion.toChatItem {
+ _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem)
+ } 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)
+ }
+ }
+ }
+ }
+
+ private struct SelectedChatItem: View {
+ @EnvironmentObject var theme: AppTheme
+ var ciId: Int64
+ @Binding var selectedChatItems: Set?
+ @State var checked: Bool = false
+ var body: some View {
+ Image(systemName: checked ? "checkmark.circle.fill" : "circle")
+ .resizable()
+ .foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel))
+ .frame(width: 24, height: 24)
+ .onAppear {
+ checked = selectedChatItems?.contains(ciId) == true
+ }
+ .onChange(of: selectedChatItems) { selected in
+ checked = selected?.contains(ciId) == true
+ }
+ }
+ }
+ }
+}
+
+private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
+ chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
+}
+
+private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) {
+ let itemIds = deletingItems
+ if itemIds.count > 0 {
+ let chatInfo = chat.chatInfo
+ Task {
+ do {
+ let deletedItems = if case .cidmBroadcast = mode,
+ moderate,
+ case .group = chat.chatInfo {
+ try await apiDeleteMemberChatItems(
+ groupId: chatInfo.apiId,
+ itemIds: itemIds
+ )
+ } else {
+ try await apiDeleteChatItems(
+ type: chatInfo.chatType,
+ id: chatInfo.apiId,
+ itemIds: itemIds,
+ mode: mode
+ )
+ }
+
+ await MainActor.run {
+ for di in deletedItems {
+ if let toItem = di.toChatItem {
+ _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
+ } else {
+ ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
+ }
+ let deletedItem = di.deletedChatItem.chatItem
+ if deletedItem.isActiveReport {
+ ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
+ }
+ }
+ }
+ await onSuccess()
+ } catch {
+ logger.error("ChatView.deleteMessages error: \(error.localizedDescription)")
+ }
+ }
+ }
+}
+
+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 {
+ contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
+ } else if case let .group(groupInfo) = chat.chatInfo {
+ groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
+ } else {
+ nil as ThemeModeOverride?
+ }
+ let overrides = if perChatTheme != nil {
+ ThemeManager.currentColors(nil, perChatTheme, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ } else {
+ nil as ThemeManager.ActiveTheme?
+ }
+ let theme = overrides ?? CurrentColors
+ return AppTheme(name: theme.name, base: theme.base, colors: theme.colors, appColors: theme.appColors, wallpaper: theme.wallpaper)
+ } else {
+ return AppTheme.shared
+ }
+}
+
+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 scrollToBottom(_ proxy: ScrollViewProxy) {
- if let ci = chatModel.reversedChatItems.first {
- withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
- }
- }
-
- private func scrollUp(_ proxy: ScrollViewProxy) {
- if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
- withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
+ 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))")
}
}
}
-@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
- Button {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
- } label: {
- if chat.chatInfo.ntfsEnabled {
- Label("Mute", systemImage: "speaker.slash")
+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 {
- Label("Unmute", systemImage: "speaker.wave.2")
+ Image(systemName: "person.crop.circle")
}
}
}
-func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
+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 {
+ 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: MsgFilter) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
- chatSettings.enableNtfs = enableNtfs ? .all : .none
+ chatSettings.enableNtfs = enableNtfs
updateChatSettings(chat, chatSettings: chatSettings)
}
@@ -1201,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
@@ -1210,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))")
@@ -1221,7 +2523,7 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
- chatModel.reversedChatItems = [
+ ItemsModel.shared.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
index bc6a96aa86..1ec46816f5 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
@@ -9,7 +9,7 @@
import SwiftUI
struct ComposeFileView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
let fileName: String
let cancelFile: (() -> Void)
let cancelEnabled: Bool
@@ -32,9 +32,8 @@ struct ComposeFileView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
- .frame(height: 50)
- .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .frame(height: 54)
+ .background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
- .padding(.top, 8)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
index edaf86912c..14026d79d1 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct ComposeImageView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
let images: [String]
let cancelImage: (() -> Void)
let cancelEnabled: Bool
@@ -18,10 +18,7 @@ struct ComposeImageView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in
- if let data = Data(base64Encoded: dropImagePrefix(image)) {
- return UIImage(data: data)
- }
- return nil
+ imageFromBase64(image)
}
if imgs.count == 0 {
ProgressView()
@@ -48,9 +45,9 @@ struct ComposeImageView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
- .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .background(theme.appColors.sentMessage)
+ .frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .padding(.top, 8)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
index cc779851ab..e629a984df 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
@@ -10,44 +10,15 @@ import SwiftUI
import LinkPresentation
import SimpleXChat
-func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
- logger.debug("getLinkMetadata: fetching URL preview")
- LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
- if let e = error {
- logger.error("Error retrieving link metadata: \(e.localizedDescription)")
- }
- if let metadata = metadata,
- let imageProvider = metadata.imageProvider,
- imageProvider.canLoadObject(ofClass: UIImage.self) {
- imageProvider.loadObject(ofClass: UIImage.self){ object, error in
- var linkPreview: LinkPreview? = nil
- if let error = error {
- logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
- } else {
- if let image = object as? UIImage,
- let resized = resizeImageToStrSize(image, maxDataSize: 14000),
- let title = metadata.title,
- let uri = metadata.originalURL {
- linkPreview = LinkPreview(uri: uri, title: title, image: resized)
- }
- }
- cb(linkPreview)
- }
- } else {
- cb(nil)
- }
- }
-}
-
struct ComposeLinkView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview?
var cancelPreview: (() -> Void)? = nil
let cancelEnabled: Bool
var body: some View {
HStack(alignment: .center, spacing: 8) {
- if let linkPreview = linkPreview {
+ if let linkPreview {
linkPreviewView(linkPreview)
} else {
ProgressView()
@@ -62,15 +33,14 @@ struct ComposeLinkView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
- .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .background(theme.appColors.sentMessage)
+ .frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .padding(.top, 8)
}
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) {
- if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
- let uiImage = UIImage(data: data) {
+ if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -82,10 +52,10 @@ struct ComposeLinkView: View {
Text(linkPreview.uri.absoluteString)
.font(.caption)
.lineLimit(1)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
.padding(.vertical, 5)
- .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
+ .frame(maxWidth: .infinity, minHeight: 60)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 604e0a276d..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,10 +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 forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
+ case reportedItem(chatItem: ChatItem, reason: ReportReason)
}
enum VoiceMessageRecordingState {
@@ -37,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,
@@ -70,24 +84,51 @@ struct ComposeState {
} else {
self.voiceMessageRecordingState = .noRecording
}
+ self.mentions = editingItem.mentions ?? [:]
+ }
+
+ init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
+ self.message = ""
+ self.parsedMessage = []
+ self.preview = .noPreview
+ 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
@@ -102,12 +143,37 @@ struct ComposeState {
}
}
+ var forwarding: Bool {
+ switch contextItem {
+ 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 || liveMessage != nil
+ default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
}
}
@@ -152,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 || liveMessage != nil || inProgress { return true }
+ if editing || forwarding || liveMessage != nil || inProgress || reporting { return true }
switch preview {
case .noPreview: return false
case .linkPreview: return false
@@ -161,6 +234,25 @@ struct ComposeState {
}
}
+ var attachmentPreview: Bool {
+ switch preview {
+ case .noPreview: false
+ case .linkPreview: false
+ case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
+ case .voicePreview: false
+ case .filePreview: true
+ }
+ }
+
+ var placeholder: String? {
+ switch contextItem {
+ case let .reportedItem(_, reason):
+ return reason.text
+ default:
+ return nil
+ }
+ }
+
var empty: Bool {
message == "" && noPreview
}
@@ -229,11 +321,16 @@ enum UploadContent: Equatable {
struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
@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
@State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set = []
@@ -253,12 +350,34 @@ 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
+ @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) {
+ Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
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)
+ let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
+ if simplexLinkProhibited {
+ msgNotAllowedView("SimpleX links not allowed", icon: "link")
+ Divider()
+ } else if fileProhibited {
+ msgNotAllowedView("Files and media not allowed", icon: "doc")
+ Divider()
+ } else if voiceProhibited {
+ msgNotAllowedView("Voice messages not allowed", icon: "mic")
+ Divider()
}
contextItemView()
switch (composeState.editing, composeState.preview) {
@@ -273,12 +392,13 @@ 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,
- !g.fullGroupPreferences.files.on {
+ !g.fullGroupPreferences.files.on(for: g.membership) {
b.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg(
title: "Files and media prohibited!",
@@ -291,6 +411,7 @@ struct ComposeView: View {
ZStack(alignment: .leading) {
SendMessageView(
composeState: $composeState,
+ selectedRange: $selectedRange,
sendMessage: { ttl in
sendMessage(ttl: ttl)
resetLinkPreview()
@@ -303,6 +424,7 @@ struct ComposeView: View {
},
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
+ disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: {
Task {
@@ -314,40 +436,46 @@ 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)
- : .accentColor
+ : theme.colors.primary
)
.padding(.trailing, 12)
- .background(.background)
- .disabled(!chat.userCanSend)
+ .disabled(!chat.chatInfo.sendMsgEnabled)
- if chat.userIsObserver {
- Text("you are observer")
+ if let disabledText {
+ Text(disabledText)
.italic()
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
- .onTapGesture {
- AlertManager.shared.showAlertMsg(
- title: "You can't send messages!",
- message: "Please contact group admin."
- )
- }
}
}
}
}
- .onChange(of: composeState.message) { _ in
+ .background {
+ Color.clear
+ .overlay(ToolbarMaterial.material(toolbarMaterial))
+ .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 composeState.message.count > 0 {
- showLinkPreview(composeState.message)
+ if msg.count > 0 {
+ showLinkPreview(parsedMsg)
} else {
resetLinkPreview()
+ hasSimplexLink = false
}
+ } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
+ (_, 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()
@@ -397,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))
@@ -489,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))
@@ -580,6 +708,7 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress
)
+ Divider()
case let .mediaPreviews(mediaPreviews: media):
ComposeImageView(
images: media.map { (img, _) in img },
@@ -588,6 +717,7 @@ struct ComposeView: View {
chosenMedia = []
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
+ Divider()
case let .voicePreview(recordingFileName, _):
ComposeVoiceView(
recordingFileName: recordingFileName,
@@ -600,6 +730,7 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback
)
+ Divider()
case let .filePreview(fileName, _):
ComposeFileView(
fileName: fileName,
@@ -607,9 +738,42 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
+ Divider()
}
}
+ private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
+ HStack {
+ Image(systemName: icon).foregroundColor(theme.colors.secondary)
+ Text(reason).italic()
+ }
+ .padding(12)
+ .frame(minHeight: 54)
+ .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 {
case .noContextItem:
@@ -617,17 +781,36 @@ 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) }
)
+ Divider()
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
- contextItem: editingItem,
+ contextItems: [editingItem],
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
+ Divider()
+ case let .forwardingItems(chatItems, _):
+ ContextItemView(
+ chat: chat,
+ 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) },
+ contextIconForeground: Color.red
+ )
+ Divider()
}
}
@@ -643,16 +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 .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 {
+ _ = 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 {
@@ -661,42 +853,67 @@ 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)
}
}
}
- await MainActor.run { clearState(live: live) }
+ await MainActor.run {
+ let wasForwarding = composeState.forwarding
+ clearState(live: live)
+ if wasForwarding,
+ chatModel.draftChatId == chat.chatInfo.id,
+ let draft = chatModel.draft {
+ composeState = draft
+ }
+ }
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 }
}
@@ -723,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 {
@@ -755,27 +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) {
- 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)
@@ -790,35 +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 []
+ }
+
+ 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,
+ itemIds: forwardedItems.map { $0.id },
+ ttl: ttl
+ ) {
+ await MainActor.run {
+ 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 chatItems
+ } else {
+ return []
}
- return nil
}
func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
- if let url = parseMessage(msgText),
+ if let parsedMsg = parseSimpleXMarkdown(msgText),
+ let url = getSimplexLink(parsedMsg).url,
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview)
@@ -829,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 {
@@ -945,9 +1198,9 @@ struct ComposeView: View {
}
}
- private func showLinkPreview(_ s: String) {
+ private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
- linkUrl = parseMessage(s)
+ (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -964,13 +1217,17 @@ struct ComposeView: View {
}
}
- private func parseMessage(_ msg: String) -> URL? {
- let parsedMsg = parseSimpleXMarkdown(msg)
- let uri = parsedMsg?.first(where: { ft in
+ 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)
- })
- if let uri = uri { return URL(string: uri.text) }
- else { return nil }
+ }) {
+ URL(string: uri.text)
+ } else {
+ nil
+ }
+ let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
+ return (url, simplexLink)
}
private func isSimplexLink(_ link: String) -> Bool {
@@ -992,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
}
}
}
@@ -1013,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/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
index 2617bc77bc..441a68fccb 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
var recordingFileName: String
@Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState
@@ -50,9 +50,9 @@ struct ComposeVoiceView: View {
}
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
- .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .background(theme.appColors.sentMessage)
+ .frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .padding(.top, 8)
}
private func recordingMode() -> some View {
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
Button {
startPlayback()
} label: {
- playPauseIcon("play.fill")
+ playPauseIcon("play.fill", theme.colors.primary)
}
Text(voiceMessageTime_(recordingTime))
case .playing:
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
audioPlayer?.pause()
playbackState = .paused
} label: {
- playPauseIcon("pause.fill")
+ playPauseIcon("pause.fill", theme.colors.primary)
}
Text(voiceMessageTime_(playbackTime))
case .paused:
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
audioPlayer?.play()
playbackState = .playing
} label: {
- playPauseIcon("play.fill")
+ playPauseIcon("play.fill", theme.colors.primary)
}
Text(voiceMessageTime_(playbackTime))
}
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
}
}
- private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
+ private func playPauseIcon(_ image: String, _ color: Color) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -147,9 +147,11 @@ struct ComposeVoiceView: View {
} label: {
Image(systemName: "multiply")
}
+ .tint(theme.colors.primary)
}
struct SliderBar: View {
+ @EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void
@@ -158,10 +160,12 @@ struct ComposeVoiceView: View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity)
.frame(height: 4)
+ .tint(theme.colors.primary)
}
}
private struct ProgressBar: View {
+ @EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -169,7 +173,7 @@ struct ComposeVoiceView: View {
GeometryReader { geometry in
ZStack {
Rectangle()
- .fill(Color.accentColor)
+ .fill(theme.colors.primary)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress)
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
index acb4f6d3e1..82090f312a 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
@@ -9,19 +9,18 @@
import SwiftUI
struct ContextInvitingContactMemberView: View {
- @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
var body: some View {
HStack {
Image(systemName: "message")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
Text("Send direct message to connect")
}
.padding(12)
- .frame(minHeight: 50)
+ .frame(minHeight: 54)
.frame(maxWidth: .infinity, alignment: .leading)
- .background(colorScheme == .light ? sentColorLight : sentColorDark)
- .padding(.top, 8)
+ .background(.thinMaterial)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index 3eb128cded..845442c75f 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -10,11 +10,13 @@ import SwiftUI
import SimpleXChat
struct ContextItemView: View {
- @Environment(\.colorScheme) var colorScheme
+ @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 {
HStack {
@@ -22,14 +24,23 @@ struct ContextItemView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
- .foregroundColor(.secondary)
- if let sender = contextItem.memberDisplayName {
- VStack(alignment: .leading, spacing: 4) {
- Text(sender).font(.caption).foregroundColor(.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 {
@@ -39,29 +50,52 @@ struct ContextItemView: View {
} label: {
Image(systemName: "multiply")
}
+ .tint(theme.colors.primary)
}
.padding(12)
- .frame(minHeight: 50)
+ .frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .background(chatItemFrameColor(contextItem, colorScheme))
- .padding(.top, 8)
+ .background(background)
}
- private func msgContentView(lines: Int) -> some View {
- MsgContentView(
- chat: chat,
- text: contextItem.text,
- formattedText: contextItem.formattedText,
- showSecrets: false
- )
- .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
- .lineLimit(lines)
+ 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(_ 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 isFileLoaded ? image("doc.fill") : Text("")
+ case .image: return image("photo")
+ case .voice: return isFileLoaded ? image("play.fill") : Text("")
+ default: return Text("")
+ }
+ }
+
+ func image(_ s: String) -> Text {
+ Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + textSpace
+ }
}
}
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 3eead5b0af..d809fd7b76 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
@@ -16,27 +16,26 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool
@Binding var height: CGFloat
@Binding var focused: Bool
- let alignment: TextAlignment
+ @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 == .leading ? .left : .right
+ field.textAlignment = alignment(text)
field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in
if !disableEditing {
- // Speed up the process of updating layout, reduce jumping content on screen
- if !isShortEmoji(newText) { updateHeight(field) }
text = newText
+ field.textAlignment = alignment(text)
+ field.updateFont()
+ // Speed up the process of updating layout, reduce jumping content on screen
+ field.updateHeight()
} else {
field.text = text
}
@@ -44,52 +43,60 @@ 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) {
- field.text = text
- field.textAlignment = alignment == .leading ? .left : .right
- updateFont(field)
- updateHeight(field)
- }
-
- 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)
+ func updateUIView(_ field: CustomUITextField, context: Context) {
+ if field.markedTextRange == nil && field.text != text {
+ field.text = text
+ field.textAlignment = alignment(text)
+ field.updateFont()
+ field.updateHeight(updateBindingNow: false)
+ }
+ if field.placeholder != placeholder {
+ field.placeholder = placeholder
+ }
+ if field.selectedRange != selectedRange {
+ field.selectedRange = selectedRange
}
- }
-
- private func updateFont(_ field: UITextView) {
- field.font = isShortEmoji(field.text)
- ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
- : UIFont.preferredFont(forTextStyle: .body)
}
}
-private class CustomUITextField: UITextView, UITextViewDelegate {
+private func alignment(_ text: String) -> NSTextAlignment {
+ isRightToLeft(text) ? .right : .left
+}
+
+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")
}
@@ -102,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
@@ -160,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
@@ -191,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
+ }
}
}
@@ -205,7 +272,9 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false),
height: Binding.constant(100),
focused: Binding.constant(false),
- alignment: TextAlignment.leading,
+ 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 8f7b23c888..e7b02c9aea 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -13,6 +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
@@ -20,6 +23,7 @@ struct SendMessageView: View {
var nextSendGrpInv: Bool = false
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
+ var disableSendButton = false
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil
@@ -29,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
@@ -38,57 +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 {
- HStack(alignment: .bottom) {
- ZStack(alignment: .leading) {
- if case .voicePreview = composeState.preview {
- Text("Voice message…")
- .font(teFont.italic())
- .multilineTextAlignment(.leading)
- .foregroundColor(.secondary)
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity)
- } else {
- let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
- NativeTextEditor(
- text: $composeState.message,
- disableEditing: $composeState.inProgress,
- height: $teHeight,
- focused: $keyboardVisible,
- alignment: alignment,
- 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)
- .overlay(
- RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
- .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
- )
}
+ .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 {
@@ -106,9 +111,12 @@ 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
+ && !composeState.forwarding
&& composeState.liveMessage == nil
&& ((composeState.noPreview && vmrs == .noRecording)
|| (vmrs == .recording && holdingVMR)) {
@@ -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)
}
@@ -184,9 +192,10 @@ struct SendMessageView: View {
!composeState.sendEnabled ||
composeState.inProgress ||
(!voiceMessageAllowed && composeState.voicePreview) ||
- composeState.endLiveDisabled
+ composeState.endLiveDisabled ||
+ disableSendButton
)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.contextMenu{
sendButtonContextMenuItems()
}
@@ -226,6 +235,7 @@ struct SendMessageView: View {
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
+ !composeState.manyMediaPreviews,
let send = sendLiveMessage,
let update = updateLiveMessage {
Button {
@@ -246,6 +256,8 @@ struct SendMessageView: View {
}
private struct RecordVoiceMessageButton: View {
+ @Environment(\.isEnabled) var isEnabled
+ @EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@Binding var holdingVMR: Bool
@@ -253,12 +265,14 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil
var body: some View {
- Button(action: {}) {
- Image(systemName: "mic.fill")
- .foregroundColor(.accentColor)
- }
+ 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 {
@@ -266,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
@@ -308,10 +320,13 @@ struct SendMessageView: View {
}
} label: {
Image(systemName: "mic")
- .foregroundColor(.secondary)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -322,7 +337,7 @@ struct SendMessageView: View {
Image(systemName: "multiply")
.resizable()
.scaledToFit()
- .foregroundColor(.accentColor)
+ .foregroundColor(theme.colors.primary)
.frame(width: 15, height: 15)
}
.frame(width: 29, height: 29)
@@ -339,7 +354,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
- .foregroundColor(.accentColor)
+ .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)
@@ -382,7 +397,7 @@ struct SendMessageView: View {
}
Task {
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
- while composeState.liveMessage != nil {
+ while await composeState.liveMessage != nil {
await update()
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
}
@@ -393,10 +408,10 @@ struct SendMessageView: View {
private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill")
- .foregroundColor(.accentColor)
+ .foregroundColor(theme.colors.primary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -412,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 {
@@ -422,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 {
@@ -432,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 86acbf6d54..e4489e46ee 100644
--- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
@@ -12,10 +12,12 @@ import SimpleXChat
struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@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!
@@ -47,7 +49,10 @@ struct ContactPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ featuresAllowed = currentFeaturesAllowed
+ dismiss()
+ }
}
}
@@ -66,8 +71,8 @@ struct ContactPreferencesView: View {
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
}
- header: { featureHeader(feature, enabled) }
- footer: { featureFooter(feature, enabled) }
+ header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) }
+ footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) }
}
private func timedMessagesFeatureSection() -> some View {
@@ -102,8 +107,8 @@ struct ContactPreferencesView: View {
infoRow("Delete after", timeText(pref.contactPreference.ttl))
}
}
- header: { featureHeader(.timedMessages, enabled) }
- footer: { featureFooter(.timedMessages, enabled) }
+ header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
+ footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
}
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
@@ -117,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 b89c006c61..7cd543af10 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -21,6 +21,7 @@ struct AddGroupMembersView: View {
struct AddGroupMembersViewCommon: View {
@EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
var chat: Chat
@State var groupInfo: GroupInfo
var creatingGroup: Bool = false
@@ -34,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito
- case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -46,14 +47,13 @@ struct AddGroupMembersViewCommon: View {
var body: some View {
if creatingGroup {
- NavigationView {
- addGroupMembersView()
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button ("Skip") { addedMembersCb(selectedContacts) }
- }
+ addGroupMembersView()
+ .navigationBarBackButtonHidden()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button ("Skip") { addedMembersCb(selectedContacts) }
}
- }
+ }
} else {
addGroupMembersView()
}
@@ -70,7 +70,7 @@ struct AddGroupMembersViewCommon: View {
if (membersToAdd.isEmpty) {
Text("No contacts to add")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
@@ -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()
@@ -90,21 +95,25 @@ struct AddGroupMembersViewCommon: View {
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
Spacer()
Text("\(count) contact(s) selected")
+ .foregroundColor(theme.colors.secondary)
}
} else {
Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing)
+ .foregroundColor(theme.colors.secondary)
}
}
}
Section {
- searchFieldView(text: $searchText, focussed: $searchFocussed)
+ searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary)
.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)
+ }
}
}
}
@@ -119,20 +128,30 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
)
case let .error(title, error):
- return Alert(title: Text(title), message: Text(error))
+ return mkAlert(title: title, message: error)
}
}
.onChange(of: selectedContacts) { _ in
searchFocussed = false
}
+ .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")
}
}
@@ -156,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)
@@ -172,14 +189,14 @@ struct AddGroupMembersViewCommon: View {
var iconColor: Color
if prohibitedToInviteIncognito {
icon = "theatermasks.circle.fill"
- iconColor = Color(uiColor: .tertiaryLabel)
+ iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
} else {
if checked {
icon = "checkmark.circle.fill"
- iconColor = .accentColor
+ iconColor = theme.colors.primary
} else {
icon = "circle"
- iconColor = Color(uiColor: .tertiaryLabel)
+ iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
}
}
return Button {
@@ -194,11 +211,10 @@ struct AddGroupMembersViewCommon: View {
}
} label: {
HStack{
- ProfileImage(imageStr: contact.image)
- .frame(width: 30, height: 30)
+ ProfileImage(imageStr: contact.image, size: 30)
.padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName)
- .foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
+ .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
Spacer()
Image(systemName: icon)
@@ -208,7 +224,7 @@ struct AddGroupMembersViewCommon: View {
}
}
-func searchFieldView(text: Binding, focussed: FocusState.Binding) -> some View {
+func searchFieldView(text: Binding, focussed: FocusState.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View {
HStack {
Image(systemName: "magnifyingglass")
.resizable()
@@ -217,8 +233,9 @@ func searchFieldView(text: Binding, focussed: FocusState.Binding)
.padding(.trailing, 10)
TextField("Search", text: text)
.focused(focussed)
- .foregroundColor(.primary)
+ .foregroundColor(onBackgroundColor)
.frame(maxWidth: .infinity)
+ .autocorrectionDisabled(true)
Image(systemName: "xmark.circle.fill")
.resizable()
.scaledToFit()
@@ -229,7 +246,7 @@ func searchFieldView(text: Binding, focussed: FocusState.Binding)
focussed.wrappedValue = false
}
}
- .foregroundColor(.secondary)
+ .foregroundColor(secondaryColor)
.frame(height: 36)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 88b36077b4..15749b0761 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -13,17 +13,23 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@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 showAddMembersSheet: Bool = false
+ @State private var groupLinkNavLinkActive: Bool = false
+ @State private var addMembersNavLinkActive: Bool = false
@State private var connectionStats: ConnectionStats?
@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
@@ -39,7 +45,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
- case error(title: LocalizedStringKey, error: LocalizedStringKey)
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -62,80 +68,114 @@ struct GroupChatInfoView: View {
NavigationView {
let members = chatModel.groupMembers
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
- .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
+ .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
- List {
- groupInfoHeader()
- .listRowBackground(Color.clear)
-
- 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()
- }
- } header: {
- Text("")
- } footer: {
- Text("Only group owners can change group preferences.")
- }
-
- Section("\(members.count + 1) members") {
- if groupInfo.canAddMembers {
- groupLinkButton()
- if (chat.chatInfo.incognito) {
- Label("Invite members", systemImage: "plus")
- .foregroundColor(Color(uiColor: .tertiaryLabel))
- .onTapGesture { alert = .cantInviteIncognitoAlert }
+ 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 {
- addMembersButton()
+ 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 {
- searchFieldView(text: $searchText, focussed: $searchFocussed)
- .padding(.leading, 8)
+
+ Section {
+ ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
+ } footer: {
+ Text("Delete chat messages from your device.")
}
- 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()
+
+ Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
+ if groupInfo.canAddMembers {
+ if groupInfo.businessChat == nil {
+ groupLinkButton()
}
- .opacity(0)
- MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
+ 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)
+ }
+ }
+
+ 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")) {
- 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)
}
}
- .navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
@@ -150,7 +190,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
- case let .error(title, error): return Alert(title: Text(title), message: Text(error))
+ case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.onAppear {
@@ -166,17 +206,15 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
- .keyboardPadding()
}
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
- ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
- .frame(width: 192, height: 192)
+ 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)
@@ -191,50 +229,167 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
- private func addMembersButton() -> some View {
- NavigationLink {
- AddGroupMembersView(chat: chat, groupInfo: groupInfo)
- .onAppear {
- searchFocussed = false
- Task {
- let groupMembers = await apiListMembers(groupInfo.groupId)
- await MainActor.run {
- chatModel.groupMembers = groupMembers.map { GMember.init($0) }
- }
+ 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)
}
}
- } label: {
- Label("Invite members", systemImage: "plus")
+ } catch {
+ logger.error("setGroupAlias error: \(responseError(error))")
+ }
+ }
+ }
+
+ func infoActionButtons() -> some View {
+ GeometryReader { g in
+ let buttonWidth = g.size.width / 4
+ HStack(alignment: .center, spacing: 8) {
+ searchButton(width: buttonWidth)
+ if groupInfo.canAddMembers {
+ addMembersActionButton(width: buttonWidth)
+ }
+ if let nextNtfMode = chat.chatInfo.nextNtfMode {
+ muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
}
}
+ private func searchButton(width: CGFloat) -> some View {
+ InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
+ dismiss()
+ onSearch()
+ }
+ .disabled(!groupInfo.ready || chat.chatItems.isEmpty)
+ }
+
+ private func addMembersActionButton(width: CGFloat) -> some View {
+ ZStack {
+ if chat.chatInfo.incognito {
+ InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
+ groupLinkNavLinkActive = true
+ }
+
+ NavigationLink(isActive: $groupLinkNavLinkActive) {
+ groupLinkDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ } else {
+ InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
+ addMembersNavLinkActive = true
+ }
+
+ NavigationLink(isActive: $addMembersNavLinkActive) {
+ addMembersDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ }
+ .disabled(!groupInfo.ready)
+ }
+
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: true))",
+ width: width
+ ) {
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
+ }
+ .disabled(!groupInfo.ready)
+ }
+
+ private func addMembersButton() -> some View {
+ 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(label, systemImage: "plus")
+ }
+ }
+
+ private func addMembersDestinationView() -> some View {
+ AddGroupMembersView(chat: chat, groupInfo: groupInfo)
+ .onAppear {
+ searchFocussed = false
+ Task {
+ await chatModel.loadGroupMembers(groupInfo)
+ }
+ }
+ }
+
private struct MemberRowView: View {
+ var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
+ @EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
var body: some View {
let member = groupMember.wrapped
- let v = HStack{
- ProfileImage(imageStr: member.image)
- .frame(width: 38, height: 38)
+ let v1 = HStack{
+ MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
- let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
+ let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
- let s = Text(member.memberStatus.shortText)
- (user ? Text ("you: ") + s : s)
+ (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
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 {
@@ -259,15 +414,30 @@ 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"
+ } else if member.activeConn?.connInactive ?? false {
+ return "inactive"
+ } else {
+ return member.memberStatus.shortText
+ }
+ }
+
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
if [.owner, .admin, .observer].contains(role) {
Text(member.memberRole.text)
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
}
}
}
@@ -278,13 +448,13 @@ struct GroupChatInfoView: View {
Button {
alert = .blockMemberAlert(mem: member)
} label: {
- Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
+ Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
} else {
Button {
alert = .unblockMemberAlert(mem: member)
} label: {
- Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
+ Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
}
}
@@ -296,13 +466,13 @@ struct GroupChatInfoView: View {
Button {
alert = .unblockForAllAlert(mem: member)
} label: {
- Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
+ Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
} else {
Button {
alert = .blockForAllAlert(mem: member)
} label: {
- Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
+ Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
}
}
@@ -318,24 +488,19 @@ struct GroupChatInfoView: View {
}
}
}
- }
- private func memberInfoView(_ groupMember: GMember) -> some View {
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
- .navigationBarHidden(false)
+ private var memberVerifiedShield: Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .baselineOffset(2)
+ .kerning(-2)
+ .foregroundColor(theme.colors.secondary)
+ }
}
private func groupLinkButton() -> some View {
NavigationLink {
- GroupLinkView(
- groupId: groupInfo.groupId,
- groupLink: $groupLink,
- groupLinkMemberRole: $groupLinkMemberRole,
- showTitle: false,
- creatingGroup: false
- )
- .navigationBarTitle("Group link")
- .navigationBarTitleDisplayMode(.large)
+ groupLinkDestinationView()
} label: {
if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus")
@@ -345,6 +510,19 @@ struct GroupChatInfoView: View {
}
}
+ private func groupLinkDestinationView() -> some View {
+ GroupLinkView(
+ groupId: groupInfo.groupId,
+ groupLink: $groupLink,
+ groupLinkMemberRole: $groupLinkMemberRole,
+ showTitle: false,
+ creatingGroup: false
+ )
+ .navigationBarTitle("Group link")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ }
+
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@@ -352,6 +530,7 @@ struct GroupChatInfoView: View {
groupProfile: groupInfo.groupProfile
)
.navigationBarTitle("Group profile")
+ .modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
@@ -366,6 +545,7 @@ struct GroupChatInfoView: View {
welcomeText: groupInfo.groupProfile.description ?? ""
)
.navigationTitle("Welcome message")
+ .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
groupInfo.groupProfile.description == nil
@@ -374,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)
}
}
@@ -393,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 {
@@ -424,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?"),
@@ -443,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)
@@ -489,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)
}
@@ -511,33 +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")
- .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!")
+ )
}
-private var memberVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
- .font(.caption)
- .baselineOffset(2)
- .kerning(-2)
- .foregroundColor(.secondary)
+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!"),
@@ -556,7 +795,9 @@ struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
- groupInfo: Binding.constant(GroupInfo.sampleData)
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ onSearch: {},
+ localAlias: ""
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index c782e2a717..a11c073a42 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -10,19 +10,21 @@ 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
private enum GroupLinkAlert: Identifiable {
case deleteLink
- case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -34,14 +36,13 @@ struct GroupLinkView: View {
var body: some View {
if creatingGroup {
- NavigationView {
- groupLinkView()
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button ("Continue") { linkCreatedCb?() }
- }
+ groupLinkView()
+ .navigationBarBackButtonHidden()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button ("Continue") { linkCreatedCb?() }
}
- }
+ }
} else {
groupLinkView()
}
@@ -70,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")
}
@@ -94,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 {
@@ -113,7 +118,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
- return Alert(title: Text(title), message: Text(error))
+ return mkAlert(title: title, message: error)
}
}
.onChange(of: groupLinkMemberRole) { _ in
@@ -133,6 +138,7 @@ struct GroupLinkView: View {
shouldCreate = false
}
}
+ .modifier(ThemedBackground(grouped: true))
}
private func createGroupLink() {
@@ -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 999617dde7..79ad242366 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -11,12 +11,18 @@ import SimpleXChat
struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
+ @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?
@@ -35,7 +41,9 @@ struct GroupMemberInfoView: View {
case abortSwitchAddressAlert
case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert)
- case error(title: LocalizedStringKey, error: LocalizedStringKey)
+ case queueInfo(info: String)
+ case someAlert(alert: SomeAlert)
+ case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -49,6 +57,8 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
+ case let .queueInfo(info): return "queueInfo \(info)"
+ case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
}
}
@@ -62,10 +72,11 @@ struct GroupMemberInfoView: View {
}
}
- private func knownDirectChat(_ contactId: Int64) -> Chat? {
+ private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
if let chat = chatModel.getContactChat(contactId),
- chat.chatInfo.contact?.directOrUsed == true {
- return chat
+ let contact = chat.chatInfo.contact,
+ contact.directOrUsed == true {
+ return (chat, contact)
} else {
return nil
}
@@ -73,23 +84,25 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View {
ZStack {
- VStack {
- let member = groupMember.wrapped
- List {
- groupMemberInfoHeader(member)
- .listRowBackground(Color.clear)
+ let member = groupMember.wrapped
+ List {
+ groupMemberInfoHeader(member)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .padding(.bottom, 18)
+
+ infoActionButtons(member)
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity)
+ .frame(height: infoViewActionButtonHeight)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+
+ if connectionLoaded {
if member.memberActive {
Section {
- if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
- knownDirectChatButton(chat)
- } else if groupInfo.fullGroupPreferences.directMessages.on {
- if let contactId = member.memberContactId {
- newDirectChatButton(contactId)
- } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
- createMemberContactButton()
- }
- }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@@ -109,8 +122,8 @@ struct GroupMemberInfoView: View {
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
- if let contactId = member.memberContactId {
- if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
+ if member.memberContactId != nil {
+ if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
@@ -118,13 +131,16 @@ struct GroupMemberInfoView: View {
}
} 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("Member") {
- 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) {
@@ -136,23 +152,17 @@ struct GroupMemberInfoView: View {
} else {
infoRow("Role", member.memberRole.text)
}
-
- // TODO invited by - need to get contact by contact id
- 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)
- }
}
if let connStats = connectionStats {
- Section("Servers") {
+ 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
+ || !member.sendMsgEnabled
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
@@ -160,11 +170,11 @@ struct GroupMemberInfoView: View {
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
- || connStats.ratchetSyncSendProhibited
+ || !member.sendMsgEnabled
)
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
+ smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
+ smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
@@ -175,41 +185,74 @@ struct GroupMemberInfoView: View {
}
if developerTools {
- Section("For console") {
+ 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 {
- if #unavailable(iOS 16) {
- // this condition prevents re-setting picker
- if !justOpened { return }
+ }
+ .navigationBarHidden(true)
+ .task {
+ if #unavailable(iOS 16) {
+ // this condition prevents re-setting picker
+ if !justOpened { return }
+ }
+ justOpened = false
+ 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
}
- 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)
- _ = chatModel.upsertGroupMember(groupInfo, mem)
- connectionStats = stats
- connectionCode = code
- } catch let error {
- logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ } 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("apiContactInfo error: \(responseError(error))")
}
}
- .onChange(of: newRole) { newRole in
- if newRole != member.memberRole {
- alert = .changeMemberRoleAlert(mem: member, role: newRole)
- }
- }
- .onChange(of: member.memberRole) { role in
- newRole = role
+ }
+ .onChange(of: newRole) { newRole in
+ if newRole != member.memberRole {
+ alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
+ .onChange(of: member.memberRole) { role in
+ newRole = role
+ }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
switch(alertItem) {
@@ -223,7 +266,9 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
- case let .error(title, error): return Alert(title: Text(title), message: Text(error))
+ 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)
}
}
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
@@ -232,6 +277,68 @@ 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))
+ }
+
+ func infoActionButtons(_ member: GroupMember) -> some View {
+ GeometryReader { g in
+ let buttonWidth = g.size.width / 4
+ HStack(alignment: .center, spacing: 8) {
+ if let chat = knownContactChat, let contact = knownContact {
+ knownDirectChatButton(chat, width: buttonWidth)
+ 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.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
+ createMemberContactButton(member, width: buttonWidth)
+ }
+ InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
+ }
+ InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
+ }
+ } else { // no known contact chat && directMessages are off
+ InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
+ }
+ InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
+ }
+ InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+
+ func showSendMessageToEnableCallsAlert() {
+ alert = .someAlert(alert: SomeAlert(
+ alert: mkAlert(
+ title: "Can't call member",
+ message: "Send message to enable calls."
+ ),
+ id: "can't call member, send message"
+ ))
+ }
+
+ 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: messageLabel
+ ),
+ id: "can't message member, direct messages prohibited"
+ ))
}
func connectViaAddressButton(_ contactLink: String) -> some View {
@@ -248,73 +355,102 @@ struct GroupMemberInfoView: View {
}
}
- func knownDirectChatButton(_ chat: Chat) -> some View {
- Button {
- dismissAllSheets(animated: true)
- DispatchQueue.main.async {
- chatModel.chatId = chat.id
- }
- } label: {
- Label("Send direct message", systemImage: "message")
- }
- }
-
- func newDirectChatButton(_ contactId: Int64) -> some View {
- Button {
- do {
- let chat = try apiGetChat(type: .direct, id: contactId)
- chatModel.addChat(chat)
+ func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
+ InfoViewButton(image: "message.fill", title: "message", width: width) {
+ ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
- DispatchQueue.main.async {
- chatModel.chatId = chat.id
- }
- } catch let error {
- logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
- } label: {
- Label("Send direct message", systemImage: "message")
}
}
- func createMemberContactButton() -> some View {
- Button {
- progressIndicator = true
+ func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
+ InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
- do {
- let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
- await MainActor.run {
- progressIndicator = false
- chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
- dismissAllSheets(animated: true)
- chatModel.chatId = memberContact.id
- chatModel.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)
- }
+ ItemsModel.shared.loadOpenChat("@\(contactId)") {
+ 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)
+ }
+ }
+ }
+ } 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"
+ ))
}
}
- } label: {
- Label("Send direct message", systemImage: "message")
}
}
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack {
- ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
- .frame(width: 192, height: 192)
+ MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
if mem.verified {
(
Text(Image(systemName: "checkmark.shield"))
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(mem.displayName)
.font(.largeTitle)
)
@@ -360,6 +496,7 @@ struct GroupMemberInfoView: View {
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
+ .modifier(ThemedBackground())
} label: {
Label(
member.verified ? "View security code" : "Verify security code",
@@ -409,7 +546,7 @@ struct GroupMemberInfoView: View {
Section {
if mem.blockedByAdmin {
Label("Blocked by admin", systemImage: "hand.raised")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
} else if mem.memberSettings.showMessages {
blockMemberButton(mem)
} else {
@@ -462,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)
}
@@ -487,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)
}
@@ -567,6 +721,21 @@ struct GroupMemberInfoView: View {
}
}
+func MemberProfileImage(
+ _ mem: GroupMember,
+ size: CGFloat,
+ color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
+ backgroundColor: Color? = nil
+) -> some View {
+ ProfileImage(
+ imageStr: mem.image,
+ size: size,
+ color: color,
+ backgroundColor: backgroundColor,
+ blurred: mem.blocked
+ )
+}
+
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member?"),
@@ -635,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))")
}
}
}
@@ -649,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 7ab4bf4ece..ed39c401ce 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -9,13 +9,21 @@
import SwiftUI
import SimpleXChat
+private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] = [
+ (nil, "all members"),
+ (.admin, "admins"),
+ (.owner, "owners")
+]
+
struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@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 {
@@ -24,13 +32,15 @@ struct GroupPreferencesView: View {
List {
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
- featureSection(.directMessages, $preferences.directMessages.enable)
+ featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
featureSection(.reactions, $preferences.reactions.enable)
- featureSection(.voice, $preferences.voice.enable)
- featureSection(.files, $preferences.files.enable)
+ 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() }
@@ -60,16 +70,19 @@ struct GroupPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ preferences = currentPreferences
+ dismiss()
+ }
}
}
- private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding) -> some View {
+ private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View {
Section {
- let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
+ 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 }
@@ -77,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,
@@ -87,6 +101,14 @@ struct GroupPreferencesView: View {
)
.frame(height: 36)
}
+ if enableFeature.wrappedValue == .on, let enableForRole {
+ Picker("Enabled for", selection: enableForRole) {
+ ForEach(featureRoles, id: \.role) { fr in
+ Text(fr.text)
+ }
+ }
+ .frame(height: 36)
+ }
} else {
settingsRow(icon, color: color) {
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
@@ -94,25 +116,25 @@ struct GroupPreferencesView: View {
if timedOn {
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
}
+ if enableFeature.wrappedValue == .on, let enableForRole {
+ HStack {
+ Text("Enabled for").foregroundColor(theme.colors.secondary)
+ Spacer()
+ Text(
+ featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
+ ?? "all members"
+ )
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
}
} footer: {
- Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
+ Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
+ .foregroundColor(theme.colors.secondary)
}
- }
-
- 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))")
+ .onChange(of: enableFeature.wrappedValue) { enabled in
+ if case .off = enabled {
+ enableForRole?.wrappedValue = nil
}
}
}
@@ -122,9 +144,10 @@ 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 00d4f8c37b..97bff70efb 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
@@ -11,18 +11,20 @@ import SimpleXChat
struct GroupWelcomeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
@State var welcomeText: String
@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() {
@@ -57,7 +59,8 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
- messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
+ 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)
}
@@ -70,7 +73,7 @@ struct GroupWelcomeView: View {
Group {
if welcomeText.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
.disabled(true)
}
TextEditor(text: $welcomeText)
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
new file mode 100644
index 0000000000..85d6b279c5
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
@@ -0,0 +1,153 @@
+//
+// SelectableChatItemToolbars.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 30.07.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct SelectedItemsTopToolbar: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ @Binding var selectedChatItems: Set?
+
+ var body: some View {
+ let count = selectedChatItems?.count ?? 0
+ return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
+ .foregroundColor(theme.colors.onBackground)
+ .frame(width: 220)
+ }
+}
+
+struct SelectedItemsBottomToolbar: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ let chatItems: [ChatItem]
+ @Binding var selectedChatItems: Set?
+ 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 forwardEnabled: Bool = false
+
+ @State var deleteCountProhibited = false
+ @State var forwardCountProhibited = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Divider()
+
+ HStack(alignment: .center) {
+ Button {
+ if canArchiveReports {
+ archiveItems()
+ } else {
+ deleteItems(deleteForEveryoneEnabled)
+ }
+ } label: {
+ Image(systemName: "trash")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
+ }
+ .disabled(!deleteEnabled || deleteCountProhibited)
+
+ Spacer()
+ Button {
+ moderateItems()
+ } label: {
+ Image(systemName: "flag")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
+ }
+ .disabled(!moderateEnabled || deleteCountProhibited)
+ .opacity(canModerate ? 1 : 0)
+
+ Spacer()
+ Button {
+ forwardItems()
+ } label: {
+ Image(systemName: "arrowshape.turn.up.forward")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
+ }
+ .disabled(!forwardEnabled || forwardCountProhibited)
+ }
+ .frame(maxHeight: .infinity)
+ .padding([.leading, .trailing], 12)
+ }
+ .onAppear {
+ recheckItems(chatInfo, chatItems, selectedChatItems)
+ }
+ .onChange(of: chatInfo) { info in
+ recheckItems(info, chatItems, selectedChatItems)
+ }
+ .onChange(of: chatItems) { items in
+ recheckItems(chatInfo, items, selectedChatItems)
+ }
+ .onChange(of: selectedChatItems) { selected in
+ recheckItems(chatInfo, chatItems, selected)
+ }
+ .frame(height: 55.5)
+ .background(.thinMaterial)
+ }
+
+ private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set?) {
+ let count = selectedItems?.count ?? 0
+ 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, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in
+ if selected.contains(ci.id) {
+ var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r
+ de = de && ci.canBeDeletedForSelf
+ 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, ar, me, onlyOwnGroupItems, fe, sel)
+ } else {
+ return r
+ }
+ }
+ moderateEnabled = me && !onlyOwnGroupItems
+ }
+ }
+
+ private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
+ return switch chatInfo {
+ case let .group(groupInfo):
+ groupInfo.membership.memberRole >= .admin
+ default: false
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
index 75e31c26ed..7b01fe0300 100644
--- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
+++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
@@ -10,6 +10,7 @@ import SwiftUI
struct VerifyCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
var displayName: String
@State var connectionCode: String?
@State var connectionVerified: Bool
@@ -30,7 +31,7 @@ struct VerifyCodeView: View {
HStack {
if connectionVerified {
Image(systemName: "checkmark.shield")
- .foregroundColor(.secondary)
+ .foregroundColor(theme.colors.secondary)
Text("\(displayName) is verified")
} else {
Text("\(displayName) is not verified")
@@ -66,6 +67,7 @@ struct VerifyCodeView: View {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.large)
.navigationTitle("Scan code")
+ .modifier(ThemedBackground())
} label: {
Label("Scan code", systemImage: "qrcode")
}
@@ -122,5 +124,6 @@ struct VerifyCodeView: View {
struct VerifyCodeView_Previews: PreviewProvider {
static var previews: some View {
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
+ .environmentObject(CurrentColors.toAppTheme())
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
index 2435c9a4f5..7abab33177 100644
--- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
@@ -10,8 +10,7 @@ import SwiftUI
struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel
- @Binding var showSettings: Bool
- @State private var newChatMenuOption: NewChatMenuOption? = nil
+ let dismissSettingsSheet: DismissAction
var body: some View {
ScrollView { chatHelp() }
@@ -24,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)
}
@@ -39,11 +38,12 @@ struct ChatHelp: View {
HStack(spacing: 8) {
Text("Tap button ")
- NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
+ NewChatMenuButton()
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)
@@ -62,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 7fbc1e4ac8..81d78fbadd 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -9,34 +9,58 @@
import SwiftUI
import SimpleXChat
-private let rowHeights: [DynamicTypeSize: CGFloat] = [
- .xSmall: 68,
- .small: 72,
- .medium: 76,
- .large: 80,
- .xLarge: 88,
- .xxLarge: 94,
- .xxxLarge: 104,
- .accessibility1: 90,
- .accessibility2: 100,
- .accessibility3: 120,
- .accessibility4: 130,
- .accessibility5: 140
+typealias DynamicSizes = (
+ rowHeight: CGFloat,
+ profileImageSize: CGFloat,
+ mediaSize: CGFloat,
+ incognitoSize: CGFloat,
+ chatInfoSize: CGFloat,
+ unreadCorner: CGFloat,
+ unreadPadding: CGFloat
+)
+
+private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
+ .xSmall: (68, 55, 33, 22, 18, 9, 3),
+ .small: (72, 57, 34, 22, 18, 9, 3),
+ .medium: (76, 60, 36, 22, 18, 10, 4),
+ .large: (80, 63, 38, 24, 20, 10, 4),
+ .xLarge: (88, 67, 41, 24, 20, 10, 4),
+ .xxLarge: (100, 71, 44, 27, 22, 11, 4),
+ .xxxLarge: (110, 75, 48, 30, 24, 12, 5),
+ .accessibility1: (110, 75, 48, 30, 24, 12, 5),
+ .accessibility2: (114, 75, 48, 30, 24, 12, 5),
+ .accessibility3: (124, 75, 48, 30, 24, 12, 5),
+ .accessibility4: (134, 75, 48, 30, 24, 12, 5),
+ .accessibility5: (144, 75, 48, 30, 24, 12, 5)
]
+private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
+
+func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
+ dynamicSizes[font] ?? defaultDynamicSizes
+}
+
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
- @Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ @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
@State private var showInvalidJSON = false
- @State private var showDeleteContactActionSheet = false
+ @State private var alert: SomeAlert? = nil
+ @State private var actionSheet: SomeActionSheet? = nil
+ @State private var sheet: SomeSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
+ var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
+
var body: some View {
Group {
switch chat.chatInfo {
@@ -63,18 +87,26 @@ 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 {
+ if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- showDeleteContactActionSheet = true
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
} label: {
- Label("Delete", systemImage: "trash")
+ deleteLabel
}
.tint(.red)
}
@@ -85,51 +117,44 @@ struct ChatListNavLink: View {
}
} else {
NavLinkPlain(
- tag: chat.chatInfo.id,
+ chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
- toggleNtfsButton(chat)
+ toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ tagChatButton(chat)
if !chat.chatItems.isEmpty {
clearChatButton()
}
Button {
- if contact.ready || !contact.active {
- showDeleteContactActionSheet = true
- } else {
- AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
- }
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
} label: {
- Label("Delete", systemImage: "trash")
+ deleteLabel
}
.tint(.red)
}
- .frame(height: rowHeights[dynamicTypeSize])
}
}
- .actionSheet(isPresented: $showDeleteContactActionSheet) {
- if contact.ready && contact.active {
- return ActionSheet(
- title: Text("Delete contact?\nThis cannot be undone!"),
- buttons: [
- .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
- .destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
- .cancel()
- ]
- )
+ .alert(item: $alert) { $0.alert }
+ .sheet(item: $sheet) {
+ if #available(iOS 16.0, *) {
+ $0.content
+ .presentationDetents([.fraction($0.fraction)])
} else {
- return ActionSheet(
- title: Text("Delete contact?\nThis cannot be undone!"),
- buttons: [
- .destructive(Text("Delete")) { Task { await deleteChat(chat) } },
- .cancel()
- ]
- )
+ $0.content
}
}
}
@@ -138,7 +163,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -158,11 +183,12 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
.swipeActions(edge: .trailing) {
+ tagChatButton(chat)
if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo)
}
@@ -172,39 +198,59 @@ struct ChatListNavLink: View {
}
default:
NavLinkPlain(
- tag: chat.chatInfo.id,
+ chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
- toggleNtfsButton(chat)
+ 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(
- tag: chat.chatInfo.id,
+ chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -222,9 +268,9 @@ struct ChatListNavLink: View {
await MainActor.run { inProgress = false }
}
} label: {
- Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
+ SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI)
}
- .tint(chat.chatInfo.incognito ? .indigo : .accentColor)
+ .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
}
@ViewBuilder private func markReadButton() -> some View {
@@ -232,16 +278,16 @@ struct ChatListNavLink: View {
Button {
Task { await markChatRead(chat) }
} label: {
- Label("Read", systemImage: "checkmark")
+ SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
}
- .tint(Color.accentColor)
+ .tint(theme.colors.primary)
} else {
Button {
Task { await markChatUnread(chat) }
} label: {
- Label("Unread", systemImage: "circlebadge.fill")
+ SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI)
}
- .tint(Color.accentColor)
+ .tint(theme.colors.primary)
}
}
@@ -251,33 +297,118 @@ struct ChatListNavLink: View {
Button {
toggleChatFavorite(chat, favorite: false)
} label: {
- Label("Unfav.", systemImage: "star.slash")
+ SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI)
}
.tint(.green)
} else {
Button {
toggleChatFavorite(chat, favorite: true)
} label: {
- Label("Favorite", systemImage: "star.fill")
+ SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI)
}
.tint(.green)
}
}
+ @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
+ 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)
+ }
+ }
+
private func clearChatButton() -> some View {
Button {
AlertManager.shared.showAlert(clearChatAlert())
} label: {
- Label("Clear", systemImage: "gobackward")
+ SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
}
.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())
} label: {
- Label("Clear", systemImage: "gobackward")
+ SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
}
.tint(Color.orange)
}
@@ -286,7 +417,7 @@ struct ChatListNavLink: View {
Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: {
- Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
+ SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI)
}
.tint(Color.yellow)
}
@@ -295,32 +426,33 @@ struct ChatListNavLink: View {
Button {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: {
- Label("Delete", systemImage: "trash")
+ deleteLabel
}
.tint(.red)
}
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) }
- } label: { Label("Accept", systemImage: "checkmark") }
- .tint(.accentColor)
+ } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
+ .tint(theme.colors.primary)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
- Label("Accept incognito", systemImage: "theatermasks")
+ SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
}
.tint(.indigo)
Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: {
- Label("Reject", systemImage: "multiply")
+ SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
}
.tint(.red)
}
- .frame(height: rowHeights[dynamicTypeSize])
+ .contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -331,38 +463,47 @@ 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
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
})
} label: {
- Label("Delete", systemImage: "trash")
+ deleteLabel
}
.tint(.red)
Button {
showContactConnectionInfo = true
} label: {
- Label("Name", systemImage: "pencil")
+ SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI)
}
- .tint(.accentColor)
+ .tint(theme.colors.primary)
}
- .frame(height: rowHeights[dynamicTypeSize])
- .sheet(isPresented: $showContactConnectionInfo) {
- if case let .contactConnection(contactConnection) = chat.chatInfo {
- ContactConnectionInfo(contactConnection: contactConnection)
- .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ .appSheet(isPresented: $showContactConnectionInfo) {
+ Group {
+ if case let .contactConnection(contactConnection) = chat.chatInfo {
+ ContactConnectionInfo(contactConnection: contactConnection)
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ .modifier(ThemedBackground(grouped: true))
+ }
}
}
+ .contentShape(Rectangle())
.onTapGesture {
showContactConnectionInfo = true
}
}
+ private var deleteLabel: some View {
+ SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
+ }
+
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) }
@@ -371,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 {
@@ -398,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) }
},
@@ -408,28 +572,6 @@ struct ChatListNavLink: View {
)
}
- private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
- Alert(
- title: Text("Reject contact request"),
- message: Text("The sender will NOT be notified"),
- primaryButton: .destructive(Text("Reject")) {
- Task { await rejectContactRequest(contactRequest) }
- },
- secondaryButton: .cancel()
- )
- }
-
- private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
- Alert(
- title: Text("Contact is not connected yet!"),
- message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
- primaryButton: .cancel(),
- secondaryButton: .destructive(Text("Delete Contact")) {
- removePendingContact(chat, contact)
- }
- )
- }
-
private func groupInvitationAcceptedAlert() -> Alert {
Alert(
title: Text("Joining group"),
@@ -437,54 +579,59 @@ struct ChatListNavLink: View {
)
}
- private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
- Alert(
- title: Text("Delete pending connection"),
- message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
- primaryButton: .destructive(Text("Delete")) {
- removePendingContact(chat, contact)
- },
- secondaryButton: .cancel()
- )
- }
-
- private func removePendingContact(_ chat: Chat, _ contact: Contact) {
- Task {
- do {
- try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
- DispatchQueue.main.async {
- chatModel.removeChat(contact.id)
- }
- } catch let error {
- logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
- }
- }
- }
-
- private func invalidJSONPreview(_ json: String) -> some View {
+ private func invalidJSONPreview(_ json: Data?) -> some View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
- .frame(height: rowHeights[dynamicTypeSize])
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture { showInvalidJSON = true }
- .sheet(isPresented: $showInvalidJSON) {
- invalidJSONView(json)
+ .appSheet(isPresented: $showInvalidJSON) {
+ invalidJSONView(dataToString(json))
.environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
}
}
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
- let ok = await connectContactViaAddress(contact.contactId, incognito)
+ let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok {
- await MainActor.run {
- chatModel.chatId = contact.id
+ 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)
+ }
+ }
+ }
+}
+
+func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
+ Alert(
+ title: Text("Reject contact request"),
+ message: Text("The sender will NOT be notified"),
+ primaryButton: .destructive(Text("Reject")) {
+ Task { await rejectContactRequest(contactRequest) }
+ },
+ secondaryButton: .cancel()
+ )
+}
+
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
Alert(
title: Text("Delete pending connection?"),
@@ -511,15 +658,14 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
)
}
-func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
+func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert {
- AlertManager.shared.showAlert(alert)
+ showAlert(alert)
return false
} else if let contact = contact {
await MainActor.run {
ChatModel.shared.updateContact(contact)
- AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
return true
}
@@ -560,18 +706,11 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
}
-struct ErrorAlert {
- var title: LocalizedStringKey
- var message: LocalizedStringKey
-}
-
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
- switch error as? ChatResponse {
- case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
- return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
- case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
- return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
- default:
+ if let r = error as? ChatError,
+ let alert = getNetworkErrorAlert(r) {
+ return alert
+ } else {
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
}
}
@@ -583,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 38aabdc21d..f34f930c6f 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -9,19 +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
- @Binding var showSettings: Bool
+ @EnvironmentObject var theme: AppTheme
+ @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 newChatMenuOption: NewChatMenuOption? = nil
- @State private var userPickerVisible = false
- @State private var showConnectDesktop = false
- @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = 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(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)
@@ -29,46 +164,59 @@ struct ChatListView: View {
viewBody
}
}
-
+
private var viewBody: some View {
- ZStack(alignment: .topLeading) {
+ ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
- set: { _ in }
+ set: { active in
+ if !active { chatModel.chatId = nil }
+ }
),
destination: chatView
- ) {
- VStack {
- if chatModel.chats.isEmpty {
- onboardingButtons()
- }
- chatListView
+ ) { chatListView }
+ }
+ .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
}
}
- if userPickerVisible {
- Rectangle().fill(.white.opacity(0.001)).onTapGesture {
- withAnimation {
- userPickerVisible.toggle()
- }
- }
- }
- UserPicker(
- showSettings: $showSettings,
- showConnectDesktop: $showConnectDesktop,
- userPickerVisible: $userPickerVisible
- )
- }
- .sheet(isPresented: $showConnectDesktop) {
- ConnectDesktopView()
}
+ .environmentObject(chatTagsModel)
}
-
+
private var chatListView: some View {
- VStack {
+ let tm = ToolbarMaterial.material(toolbarMaterial)
+ return withToolbar(tm) {
chatList
+ .background(theme.colors.background)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarHidden(searchMode || oneHandUI)
+ }
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .onAppear {
+ if #unavailable(iOS 16.0), !viewOnScreen {
+ viewOnScreen = true
+ }
+ }
+ .onDisappear {
+ activeUserPickerSheet = nil
+ if #unavailable(iOS 16.0) {
+ viewOnScreen = false
+ }
}
- .onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -85,66 +233,112 @@ struct ChatListView: View {
secondaryButton: .cancel()
))
}
- .listStyle(.plain)
- .navigationBarTitleDisplayMode(.inline)
- .navigationBarHidden(searchMode)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- let user = chatModel.currentUser ?? User.sampleData
- ZStack(alignment: .topTrailing) {
- ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
- .frame(width: 32, height: 32)
- .padding(.trailing, 4)
- let allRead = chatModel.users
- .filter { u in !u.user.activeUser && !u.user.hidden }
- .allSatisfy { u in u.unreadCount == 0 }
- if !allRead {
- unreadBadge(size: 12)
- }
- }
- .onTapGesture {
- if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
- withAnimation {
- userPickerVisible.toggle()
- }
- } else {
- showSettings = true
- }
- }
+ .safeAreaInset(edge: .top) {
+ if oneHandUI { Divider().background(tm) }
+ }
+ .safeAreaInset(edge: .bottom) {
+ if oneHandUI {
+ Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
- ToolbarItem(placement: .principal) {
- HStack(spacing: 4) {
- Text("Chats")
- .font(.headline)
- if chatModel.chats.count > 0 {
- toggleFilterButton()
- }
- }
- .frame(maxWidth: .infinity, alignment: .center)
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- switch chatModel.chatRunning {
- case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
- case .some(false): chatStoppedIcon()
- case .none: EmptyView()
- }
+ }
+ .sheet(item: $sheet) { sheet in
+ if #available(iOS 16.0, *) {
+ sheet.content.presentationDetents([.fraction(sheet.fraction)])
+ } else {
+ sheet.content
}
}
}
-
- private func toggleFilterButton() -> some View {
- Button {
- showUnreadAndFavorites = !showUnreadAndFavorites
- } label: {
- Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
- .foregroundColor(.accentColor)
+
+ 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 {
+ content()
+ .toolbarBackground(.hidden, for: .bottomBar)
+ .toolbar { bottomToolbar }
+ } else {
+ content()
+ .toolbarBackground(.automatic, for: .navigationBar)
+ .toolbarBackground(material)
+ .toolbar { topToolbar }
+ }
+ } else {
+ if oneHandUI {
+ content().toolbar { bottomToolbarGroup() }
+ } else {
+ content().toolbar { topToolbar }
+ }
}
}
-
- @ViewBuilder private var chatList: some View {
+
+ @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) {
+ HStack {
+ leadingToolbarItem.padding(.bottom, padding)
+ Spacer()
+ SubsStatusIndicator().padding(.bottom, padding)
+ Spacer()
+ trailingToolbarItem.padding(.bottom, padding)
+ }
+ .contentShape(Rectangle())
+ .onTapGesture { scrollToSearchBar = true }
+ }
+ }
+
+ @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
+ let padding: Double = Self.hasHomeIndicator ? 0 : 14
+ ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
+ leadingToolbarItem.padding(.bottom, padding)
+ Spacer()
+ SubsStatusIndicator().padding(.bottom, padding)
+ Spacer()
+ 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([.top, .trailing], 3)
+ let allRead = chatModel.users
+ .filter { u in !u.user.activeUser && !u.user.hidden }
+ .allSatisfy { u in u.unreadCount == 0 }
+ if !allRead {
+ unreadBadge(size: 12)
+ }
+ }
+ .onTapGesture {
+ userPickerShown = true
+ }
+ }
+
+ @ViewBuilder var trailingToolbarItem: some View {
+ switch chatModel.chatRunning {
+ case .some(true): NewChatMenuButton()
+ case .some(false): chatStoppedIcon()
+ case .none: EmptyView()
+ }
+ }
+
+ private var chatList: some View {
let cs = filteredChats()
- ZStack {
- VStack {
+ return ZStack {
+ ScrollViewReader { scrollProxy in
List {
if !chatModel.chats.isEmpty {
ChatListSearchBar(
@@ -152,148 +346,242 @@ 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)
+ .listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
+ .padding(.top, oneHandUI ? 8 : 0)
+ .id("searchBar")
}
- ForEach(cs, id: \.viewId) { chat in
- ChatListNavLink(chat: chat)
- .padding(.trailing, -16)
+ if #available(iOS 16.0, *) {
+ ForEach(cs, id: \.viewId) { chat in
+ 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))
+ .listRowBackground(Color.clear)
+ }
+ .offset(x: -8)
+ } else {
+ ForEach(cs, id: \.viewId) { chat in
+ ChatListNavLink(chat: chat, parentSheet: $sheet)
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets())
+ .background { theme.colors.background } // Hides default list selection colour
.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)
}
- .offset(x: -8)
}
- }
- .onChange(of: chatModel.chatId) { _ in
- if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
- chatModel.chatToTop = nil
- chatModel.popChat(chatId)
+ .listStyle(.plain)
+ .onChange(of: chatModel.chatId) { currentChatId in
+ if let chatId = chatModel.chatToTop, currentChatId != chatId {
+ chatModel.chatToTop = nil
+ chatModel.popChat(chatId)
+ }
+ stopAudioPlayer()
+ }
+ .onChange(of: chatModel.currentUser?.userId) { _ in
+ stopAudioPlayer()
+ }
+ .onChange(of: scrollToSearchBar) { scrollToSearchBar in
+ if scrollToSearchBar {
+ Task { self.scrollToSearchBar = false }
+ withAnimation { scrollProxy.scrollTo("searchBar") }
+ }
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
- Text("No filtered chats").foregroundColor(.secondary)
+ 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(.accentColor)
+ .foregroundColor(theme.colors.primary)
}
-
- private func onboardingButtons() -> some View {
- VStack(alignment: .trailing, spacing: 0) {
- Path { p in
- p.move(to: CGPoint(x: 8, y: 0))
- p.addLine(to: CGPoint(x: 16, y: 10))
- p.addLine(to: CGPoint(x: 0, y: 10))
- p.addLine(to: CGPoint(x: 8, y: 0))
- }
- .fill(Color.accentColor)
- .frame(width: 20, height: 10)
- .padding(.trailing, 12)
-
- connectButton("Tap to start a new chat") {
- newChatMenuOption = .newContact
- }
-
- Spacer()
- Text("You have no chats")
- .foregroundColor(.secondary)
- .frame(maxWidth: .infinity)
- }
- .padding(.trailing, 6)
- .frame(maxHeight: .infinity)
- }
-
- private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Text(label)
- .padding(.vertical, 10)
- .padding(.horizontal, 20)
- }
- .background(Color.accentColor)
- .foregroundColor(.white)
- .clipShape(RoundedRectangle(cornerRadius: 16))
- }
-
+
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
- ChatView(chat: chat).onAppear {
- loadChat(chat: chat)
- }
+ 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
- ? chatModel.chats
+ return s == ""
+ ? chatModel.chats.filter { chat in
+ !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 s == ""
- ? filtered(chat)
- : (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
+ !contact.chatDeleted && !chat.chatInfo.contactCard && (
+ ( viewNameContains(cInfo, s) ||
+ contact.profile.displayName.localizedLowercase.contains(s) ||
+ contact.fullName.localizedLowercase.contains(s)
+ )
+ )
+ 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 {
+ @State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
+ @State private var hasSess: Bool = false
+ @State private var task: Task?
+ @State private var showServersSummary = false
+
+ @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
+
+ var body: some View {
+ Button {
+ showServersSummary = true
+ } label: {
+ HStack(spacing: 4) {
+ Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
+ SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
+ if showSubscriptionPercentage {
+ SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
+ }
+ }
+ }
+ .disabled(ChatModel.shared.chatRunning != true)
+ .onAppear {
+ startTask()
+ }
+ .onDisappear {
+ stopTask()
+ }
+ .appSheet(isPresented: $showServersSummary) {
+ ServersSummaryView()
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath