diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml
new file mode 100644
index 0000000000..d64d579520
--- /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.2.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 60bd0cb729..e2ea1072fb 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,17 +22,58 @@ on:
- "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
@@ -42,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
@@ -52,129 +94,118 @@ 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 }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- - os: ubuntu-20.04
+ - os: 22.04
+ os_underscore: 22_04
+ arch: x86_64
+ runner: "ubuntu-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
- desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- - os: macos-latest
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-macos-aarch64
- desktop_asset_name: simplex-desktop-macos-aarch64.dmg
- - os: macos-13
- ghc: "9.6.3"
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-macos-x86-64
- desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- - os: windows-latest
- ghc: "9.6.3"
- cache_path: C:/cabal
- asset_name: simplex-chat-windows-x86-64
- desktop_asset_name: simplex-desktop-windows-x86_64.msi
-
+ should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
+ - os: 22.04
+ os_underscore: 22_04
+ arch: x86_64
+ runner: "ubuntu-22.04"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ - os: 24.04
+ os_underscore: 24_04
+ arch: x86_64
+ runner: "ubuntu-24.04"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ - os: 22.04
+ os_underscore: 22_04
+ arch: aarch64
+ runner: "ubuntu-22.04-arm"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ - os: 24.04
+ os_underscore: 24_04
+ arch: aarch64
+ runner: "ubuntu-24.04-arm"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
steps:
- - name: Skip unreliable ghc 8.10.7 build on stable branch
- if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable'
- run: exit 0
-
- - 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
+
+ - name: Get UID and GID
+ id: ids
+ run: |
+ echo "uid=$(id -u)" >> $GITHUB_OUTPUT
+ echo "gid=$(id -g)" >> $GITHUB_OUTPUT
+
+ # 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 }}-${{ matrix.arch }}-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 }}
+ USER_UID=${{ steps.ids.outputs.uid }}
+ USER_GID=${{ steps.ids.outputs.gid }}
+
+ # Docker needs these flags for AppImage build:
+ # --device /dev/fuse
+ # --cap-add SYS_ADMIN
+ # --security-opt apparmor:unconfined
+ - name: Start container
+ if: matrix.should_run == true
shell: bash
run: |
- echo "ignore-project: False" >> cabal.project.local
- echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
- echo " flags: +openssl" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package jpeg-turbo" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/libjpeg-turbo/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/libjpeg-turbo/lib" >> cabal.project.local
- echo " flags: +static" >> cabal.project.local
+ docker run -t -d \
+ --device /dev/fuse \
+ --cap-add SYS_ADMIN \
+ --security-opt apparmor:unconfined \
+ --name builder \
+ -v ~/.cabal:/root/.cabal \
+ -v /home/runner/work/_temp:/home/runner/work/_temp \
+ -v ${{ github.workspace }}:/project \
+ build/${{ matrix.os }}:latest
- - name: Unix prepare cabal.project.local for Mac
- if: matrix.os == 'macos-13'
- shell: bash
- run: |
- echo "ignore-project: False" >> cabal.project.local
- echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
- echo " flags: +openssl" >> cabal.project.local
- echo "" >> cabal.project.local
- echo "package jpeg-turbo" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/libjpeg-turbo/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/libjpeg-turbo/lib" >> cabal.project.local
- echo " flags: +static" >> cabal.project.local
-
- - name: Install AppImage dependencies
- if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
- run: sudo apt install -y desktop-file-utils
-
- - name: Install Linux dependencies
- if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
- run: sudo apt install -y libturbojpeg0-dev
-
- - name: Install pkg-config for Mac
- if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
- run: brew install openssl@3.0 pkg-config jpeg-turbo
-
- - 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
@@ -184,68 +215,234 @@ jobs:
echo "package jpeg-turbo" >> cabal.project.local
echo " flags: +static-gcc" >> 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: |
+ 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: Build CLI deb
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ version=${{ github.ref }}
+ version=${version#refs/tags/v}
+ version=${version%-*}
+
+ ./scripts/desktop/build-cli-deb.sh "$version"
+
+ - 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
+ - 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: |
+ cli_name="simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
+ cli_deb_name="${cli_name}.deb"
+ cli_path="${{ github.workspace }}"
+
+ docker cp builder:/out/simplex-chat "./${cli_name}"
+ docker cp builder:/out/deb-build/simplex-chat.deb "./${cli_deb_name}"
+
+ echo "bin_name=${cli_name}" >> $GITHUB_OUTPUT
+ echo "bin_path=${cli_path}/${cli_name}" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${cli_name}\)= $(openssl sha256 "${cli_path}/${cli_name}" | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ echo "deb_name=${cli_deb_name}" >> $GITHUB_OUTPUT
+ echo "deb_path=${cli_path}/${cli_deb_name}" >> $GITHUB_OUTPUT
+ echo "deb_hash=$(echo SHA2-256\(${cli_deb_name}\)= $(openssl sha256 "${cli_path}/${cli_deb_name}" | 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:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.unix_cli_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
+ bin_name: ${{ steps.linux_cli_prepare.outputs.bin_name }}
+ bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
+ bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- - 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 }}
+ - name: Upload CLI deb
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ uses: ./.github/actions/prepare-release
with:
- append_body: true
- body: |
- ${{ steps.unix_cli_build.outputs.bin_hash }}
+ bin_name: ${{ steps.linux_cli_prepare.outputs.deb_name }}
+ bin_path: ${{ steps.linux_cli_prepare.outputs.deb_path }}
+ bin_hash: ${{ steps.linux_cli_prepare.outputs.deb_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- - 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: Build Desktop
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ scripts/desktop/make-deb-linux.sh
- - name: Linux build desktop
+ - name: Prepare 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')
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
- scripts/desktop/build-lib-linux.sh
- cd apps/multiplatform
- ./gradlew packageDeb
- path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_${{ matrix.arch }}.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
+ echo "package_hash=$(echo SHA2-256\(simplex-desktop-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}.deb\)= $(openssl sha256 $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'
- shell: bash
+ - 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: simplex-desktop-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}.deb
+ 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
- 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
- - name: Mac build desktop
+ - 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-${{ matrix.arch }}.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-${{ matrix.arch }}.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 && matrix.arch == 'x86_64'
+ 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-15-intel
+ 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
+ echo "" >> cabal.project.local
+ echo "package jpeg-turbo" >> cabal.project.local
+ echo " extra-include-dirs: /opt/homebrew/opt/libjpeg-turbo/include" >> cabal.project.local
+ echo " extra-lib-dirs: /opt/homebrew/opt/libjpeg-turbo/lib" >> cabal.project.local
+ echo " flags: +static" >> cabal.project.local
+
+ - name: Build CLI
+ id: mac_cli_build
+ shell: bash
+ run: |
+ cabal build -j --enable-tests
+ path=$(cabal list-bin simplex-chat)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Desktop
id: mac_desktop_build
- if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
+ if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@@ -255,88 +452,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' || matrix.os == 'macos-13')
- uses: svenstaro/upload-release-action@v2
- with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.mac_desktop_build.outputs.package_path }}
- asset_name: ${{ matrix.desktop_asset_name }}
- tag: ${{ github.ref }}
-
- - name: Mac update desktop package hash
- if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.mac_desktop_build.outputs.package_hash }}
-
- - name: Cache unix build
- uses: actions/cache/save@v3
- if: matrix.os != 'windows-latest'
- with:
- path: |
- ${{ matrix.cache_path }}
- dist-newstyle
- key: ${{ steps.restore_cache.outputs.cache-primary-key }}
-
- - name: Unix test
- if: matrix.os != 'windows-latest'
- timeout-minutes: 40
+ - name: Run tests
+ timeout-minutes: 120
shell: bash
- run: cabal test --test-show-details=direct
+ run: |
+ i=1
+ attempts=1
+ ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
+ while [ "$i" -le "$attempts" ]; do
+ if cabal test --test-show-details=direct; then
+ break
+ else
+ echo "Attempt $i failed, retrying..."
+ i=$((i + 1))
+ sleep 1
+ fi
+ done
+ if [ "$i" -gt "$attempts" ]; then
+ echo "All "$attempts" attempts failed."
+ exit 1
+ fi
- # Unix /
+# =========================
+# Windows Build
+# =========================
- # / Windows
- # 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
@@ -349,10 +535,9 @@ 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)
@@ -369,70 +554,42 @@ jobs:
rm -rf dist-newstyle/src/direct-sq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal
- cabal build --enable-tests
+ cabal build -j --enable-tests
rm -rf dist-newstyle/src/direct-sq*
path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT
- echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload CLI binary to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: svenstaro/upload-release-action@v2
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.windows_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
+ bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- - name: Windows update CLI binary hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.windows_build.outputs.bin_hash }}
-
- - name: Windows build desktop
+ - name: Build Desktop
id: windows_desktop_build
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
+ if: startsWith(github.ref, 'refs/tags/v')
shell: msys2 {0}
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew packageMsi
+ rm -rf dist-newstyle/src/direct-sq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
- echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload desktop package to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: svenstaro/upload-release-action@v2
+ - name: Upload Desktop
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.windows_desktop_build.outputs.package_path }}
- asset_name: ${{ matrix.desktop_asset_name }}
- tag: ${{ github.ref }}
-
- - name: Windows update desktop package hash
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: softprops/action-gh-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- append_body: true
- body: |
- ${{ steps.windows_desktop_build.outputs.package_hash }}
-
- - name: Cache windows build
- uses: actions/cache/save@v3
- if: matrix.os == 'windows-latest'
- with:
- path: |
- ${{ matrix.cache_path }}
- dist-newstyle
- key: ${{ steps.restore_cache.outputs.cache-primary-key }}
-
- # Windows /
+ bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml
new file mode 100644
index 0000000000..0febed4c87
--- /dev/null
+++ b/.github/workflows/reproduce-schedule.yml
@@ -0,0 +1,52 @@
+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: 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: Checkout code
+ uses: actions/checkout@v3
+ with:
+ ref: ${{ env.TAG }}
+ repository: simplex-chat/simplex-chat
+
+ # Otherwise we run out of disk space with Docker build
+ - name: Free disk space
+ shell: bash
+ run: ./scripts/ci/linux_util_free_space.sh
+ - name: Execute reproduce script
+ run: |
+ ${GITHUB_WORKSPACE}/scripts/simplex-chat-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}-simplex-chat/_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 6839d48aeb..5fbe8293bc 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -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 645b55ec9d..bf565453a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,7 @@ website/translations.json
website/src/img/images/
website/src/images/
website/src/js/lottie.min.js
+website/src/js/ethers*
website/src/privacy.md
# Generated files
website/package/generated*
@@ -79,3 +80,4 @@ website/package-lock.json
website/.cache
website/test/stubs-layout-cache/_includes/*.js
apps/android/app/release
+apps/multiplatform/.kotlin/sessions
diff --git a/Dockerfile b/Dockerfile
index 7b9641777a..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 --constraint 'simplexmq +client_library'
+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..3ddff59d12
--- /dev/null
+++ b/Dockerfile.build
@@ -0,0 +1,134 @@
+# 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.2.0
+ARG JAVA_VER=17.0.17.10.1
+ARG JAVA_HASH_AMD64=e3e11daa5c22a45153bbeff1a0c21bf08631791e4e8d8ed14deba31c7cf9af1a
+ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514acbc
+
+ENV TZ=Etc/UTC \
+ DEBIAN_FRONTEND=noninteractive
+
+ARG USER_UID=1000
+ARG USER_GID=1000
+ARG USER_NAME=builder
+
+# Install curl, git and and simplex-chat dependencies
+RUN apt-get update && \
+ apt-get install -y curl \
+ libpq-dev \
+ git \
+ strip-nondeterminism \
+ 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 \
+ zipalign \
+ apksigner \
+ python3 \
+ python3-venv \
+ xz-utils \
+ 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 export JAVA_FILENAME='java-corretto.deb' \
+ JAVA_VER_MAJOR=$(printf "${JAVA_VER}" | awk -F. '{print $1}') \
+ JAVA_VER_DEB=$(printf "${JAVA_VER}" | sed 's/\.1$/-1/') && \
+ case "$(uname -m)" in \
+ x86_64) export JAVA_ARCH='amd64' JAVA_HASH="$JAVA_HASH_AMD64" ;; \
+ aarch64) export JAVA_ARCH='arm64' JAVA_HASH="$JAVA_HASH_ARM64" ;; \
+ *) echo "unknown arch $(uname -m)" && exit 1 ;; \
+ esac && \
+ curl --proto '=https' --tlsv1.2 -sSf \
+ "https://corretto.aws/downloads/resources/${JAVA_VER}/java-${JAVA_VER_MAJOR}-amazon-corretto-jdk_${JAVA_VER_DEB}_${JAVA_ARCH}.deb" \
+ -o "${JAVA_FILENAME}" && \
+ if echo "${JAVA_HASH} ${JAVA_FILENAME}" | sha256sum -c -; then \
+ if apt install -y ./"${JAVA_FILENAME}"; then \
+ rm ./"${JAVA_FILENAME}"; \
+ else \
+ echo "Failed to install Java Corretto" && exit 1; \
+ fi \
+ else \
+ echo "Checksum mismatch" && exit 1; \
+ fi
+
+RUN userdel -r ubuntu || :
+RUN groupadd -g ${USER_GID} ${USER_NAME} || :; useradd -u ${USER_UID} -g ${USER_GID} --create-home --shell /bin/bash ${USER_NAME} || :
+RUN mkdir /nix /out && chown ${USER_NAME}:${USER_NAME} /nix /out
+USER ${USER_NAME}
+WORKDIR /home/${USER_NAME}
+
+# 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
+
+# Setup basic env variables (required)
+ENV HOME="/home/${USER_NAME}" USER="${USER_NAME}"
+# Adjust PATH
+ENV PATH="$HOME/.cabal/bin:$HOME/.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="$HOME"
+
+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 "$HOME/.android" "$HOME/.gradle" && \
+ touch "$HOME/.android/repositories.cfg" && \
+ echo 'org.gradle.console=plain' > "$HOME/.gradle/gradle.properties" &&\
+ yes | sdkmanager --licenses >/dev/null
+
+ENV PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools"
+
+# Android reproducibility scripts
+RUN python3 -m venv "$HOME/.venv"
+RUN "$HOME/.venv/bin/pip" install apksigcopier repro-apk
+
+ENV PATH="$HOME/.venv/bin:$PATH"
+
+WORKDIR /project
diff --git a/PRIVACY.md b/PRIVACY.md
index 7c4bfbf660..18e5539726 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -123,6 +123,16 @@ This section applies only to the experimental group directory operated by Simple
[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
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.
@@ -131,9 +141,9 @@ The app includes support contact operated by SimpleX Chat Ltd. If you contact su
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
-Preset server operators will not provide general access to their servers or the data on their servers to each other.
+Preset server operators must not provide general access to their servers or the data on their servers to each other.
-Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information 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
@@ -148,7 +158,7 @@ The cases when the preset server operators may share the data temporarily stored
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- 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, the preset server operators 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 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.
+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).
@@ -190,7 +200,18 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**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 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; 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.
+**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.
+
+**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.
@@ -222,4 +243,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**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 November 14, 2024
+Updated March 3, 2025
diff --git a/README.md b/README.md
index 936667da8c..b1d2556072 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
-[
](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [
](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [
](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
+[
](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [
](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [
](https://www.whonix.org/wiki/Chat#Recommendation) [
](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat!
@@ -24,15 +24,15 @@
## Install the app
-[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
+[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
-[](https://play.google.com/store/apps/details?id=chat.simplex.app)
+[](https://play.google.com/store/apps/details?id=chat.simplex.app)
-[
](https://app.simplex.chat)
+[
](https://app.simplex.chat)
-[
](https://testflight.apple.com/join/DWuT2LQu)
+[
](https://testflight.apple.com/join/DWuT2LQu)
-[
](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
+[
](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
@@ -54,38 +54,20 @@ If you are interested in helping us to integrate open-source language models, an
## Join user groups
-You can join the groups created by other users via the new [directory service](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). We are not responsible for the content shared in these groups.
+You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](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) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups.
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
-You also can:
-- criticize the app, and make comparisons with other messengers.
-- share new messengers you think could be interesting for privacy, as long as you don't spam.
-- share some privacy related publications, infrequently.
-- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM)
-You must:
-- be polite to other users
-- avoid spam (too frequent messages, even if they are relevant)
-- avoid any personal attacks or hostility.
-- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
-
-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=2-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)
-
-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:
+There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform:
- chat bots and automations
- integrations with other apps
- social apps and services
- etc.
-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-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.
+You can join these and other groups by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
## Follow our updates
@@ -102,7 +84,7 @@ You need to share a link with your friend or scan a QR code from their phone, in
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
-
+
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
@@ -110,6 +92,14 @@ 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:
+
+- [develop a chat bot](#develop-a-chat-bot) for SimpleX Chat!
+- writing a tutorial or recipes about hosting servers, chat bots, etc.
+- developing features - please connect to us via chat so we can help you get started.
+
## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@@ -141,15 +131,6 @@ Join our translators to help SimpleX grow!
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
-## Contribute
-
-We would love to have you join the development! You can help us with:
-
-- [share the color theme](./docs/THEMES.md) you use in Android app!
-- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
-- contributing to SimpleX Chat knowledge-base.
-- developing features - please connect to us via chat so we can help you get started.
-
## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -166,9 +147,9 @@ It is possible to donate via:
- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
-- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
-- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
+- ETH/USDT (Ethereum, Arbitrum One): 0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
+- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins.
@@ -193,6 +174,7 @@ SimpleX Chat founder
- [SimpleX Platform design](#simplex-platform-design)
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
- [For developers](#for-developers)
+- [Develop a chat bot](#develop-a-chat-bot)
- [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -234,6 +216,14 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
+[Jul 29, 2025 SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md)
+
+[Jul 3, 2025 SimpleX network: new experience of connecting with people — available in SimpleX Chat v6.4-beta.4](./blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.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)
@@ -305,27 +295,36 @@ What is already implemented:
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. 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 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.
+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
You can:
+- [create chat bots and services](#develop-a-chat-bot).
+- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
-- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
-- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
If you are considering developing with SimpleX platform please get in touch for any advice and support.
Please also join [#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) group to ask any questions and share your success stories.
+## Develop a chat bot
+
+You can create a chat bot or any chat-based service in any language running SimpleX Chat terminal CLI as a local WebSocket server.
+
+See [our new bot API reference](./bots/README.md). Most of it is automatically generated from core library types, so it stays up to date.
+
+Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
+
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
@@ -424,14 +423,16 @@ Please do NOT report security vulnerabilities via GitHub issues.
## License
-[AGPL v3](./LICENSE)
+This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file.
-[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
+Graphic designs, artworks and layouts are not licensed for re-use. If you want to use them in your publications, please ask for permission. Texts can be used as direct quotes, referencing the source.
+
+[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
-[](https://play.google.com/store/apps/details?id=chat.simplex.app)
+[](https://play.google.com/store/apps/details?id=chat.simplex.app)
-[
](https://app.simplex.chat)
+[
](https://app.simplex.chat)
-[
](https://testflight.apple.com/join/DWuT2LQu)
+[
](https://testflight.apple.com/join/DWuT2LQu)
-[
](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
+[
](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift
index ad8c661e1c..3f6998c9ec 100644
--- a/apps/ios/Shared/AppDelegate.swift
+++ b/apps/ios/Shared/AppDelegate.swift
@@ -54,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active
} catch {
- if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr {
+ if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr {
m.tokenStatus = .expired
}
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
new file mode 100644
index 0000000000..cb29f09fe1
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "vertical_logo_x1.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "vertical_logo_x2.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "vertical_logo_x3.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png
new file mode 100644
index 0000000000..f916e43ea9
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png
new file mode 100644
index 0000000000..bb35878f0c
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png
new file mode 100644
index 0000000000..c55f481b36
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png differ
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 652258415e..7adf7a0435 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -11,12 +11,10 @@ import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
- case updatedConditions
var id: String {
switch self {
case .whatsNew: return "whatsNew"
- case .updatedConditions: return "updatedConditions"
}
}
}
@@ -47,21 +45,10 @@ struct ContentView: View {
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
- @State private var chatListActionSheet: ChatListActionSheet? = nil
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40
- private enum ChatListActionSheet: Identifiable {
- case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
-
- var id: String {
- switch self {
- case let .planAndConnectSheet(sheet): return sheet.id
- }
- }
- }
-
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
@@ -76,7 +63,7 @@ struct ContentView: View {
}
}
- @ViewBuilder func allViews() -> some View {
+ func allViews() -> some View {
ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@@ -183,11 +170,6 @@ struct ContentView: View {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
- .actionSheet(item: $chatListActionSheet) { sheet in
- switch sheet {
- case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
- }
- }
} else {
OnboardingView(onboarding: step)
}
@@ -211,7 +193,7 @@ struct ContentView: View {
}
}
- @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
+ private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer()
@@ -278,18 +260,18 @@ struct ContentView: View {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
- if showWhatsNew {
+ if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
- } else if showUpdatedConditions {
- noticesSheetItem = .updatedConditions
}
}
}
}
prefShowLANotice = true
connectViaUrl()
+ showReRegisterTokenAlert()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
+ .onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
.sheet(item: $noticesSheetItem) { item in
switch item {
case let .whatsNew(updatedConditions):
@@ -298,13 +280,6 @@ struct ContentView: View {
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
- case .updatedConditions:
- UsageConditionsView(
- currUserServers: Binding.constant([]),
- userServers: Binding.constant([])
- )
- .modifier(ThemedBackground(grouped: true))
- .task { await setConditionsNotified_() }
}
}
if chatModel.setDeliveryReceipts {
@@ -315,6 +290,12 @@ 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 {
@@ -446,30 +427,47 @@ struct ContentView: View {
}
func connectViaUrl() {
+ let m = ChatModel.shared
+ if let url = m.appOpenUrl {
+ m.appOpenUrl = nil
+ connectViaUrl_(url)
+ } else if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active {
+ // correcting branch in case .onChange(of: scenePhase) in SimpleXApp doesn't trigger and transfer appOpenUrlLater into appOpenUrl
+ m.appOpenUrlLater = nil
+ connectViaUrl_(url)
+ }
+ }
+
+ func connectViaUrl_(_ url: URL) {
dismissAllSheets() {
- let m = ChatModel.shared
- if let url = m.appOpenUrl {
- m.appOpenUrl = nil
- var path = url.path
- if (path == "/contact" || path == "/invitation") {
- path.removeFirst()
- let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
- planAndConnect(
- link,
- showAlert: showPlanAndConnectAlert,
- showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
- dismiss: false,
- incognito: nil
- )
- } else {
- AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
- }
+ var path = url.path
+ 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(
+ link,
+ theme: theme,
+ dismiss: false
+ )
+ } else {
+ AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
}
}
}
- private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
- AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
+ 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()
+ ))
+ }
+ }
}
}
diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift
new file mode 100644
index 0000000000..193b675a57
--- /dev/null
+++ b/apps/ios/Shared/Model/AppAPITypes.swift
@@ -0,0 +1,2346 @@
+//
+// 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 apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool)
+ 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, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
+ case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64)
+ case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, 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, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
+ case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, 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, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction)
+ case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
+ case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64])
+ case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, 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 apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
+ case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: 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)
+ case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
+ case apiDeleteGroupLink(groupId: Int64)
+ case apiGetGroupLink(groupId: Int64)
+ case apiAddGroupShortLink(groupId: Int64)
+ case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
+ case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
+ case apiAcceptMemberContact(contactId: Int64)
+ 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, incognito: Bool)
+ case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
+ case apiChangeConnectionUser(connId: Int64, userId: Int64)
+ case apiConnectPlan(userId: Int64, connLink: String)
+ case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData)
+ case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData)
+ case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
+ case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64)
+ case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?)
+ case apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?)
+ 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)
+ case apiDeleteMyAddress(userId: Int64)
+ case apiShowMyAddress(userId: Int64)
+ case apiAddMyAddressShortLink(userId: Int64)
+ case apiSetProfileAddress(userId: Int64, on: Bool)
+ case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings)
+ 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 apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?)
+ case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, 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 .apiSetUserAutoAcceptMemberContacts(userId, enable):
+ return "/_set accept member contacts \(userId) \(onOff(enable))"
+ 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, scope, contentTag, pagination, search):
+ let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : ""
+ return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)")
+ case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)"
+ case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
+ let msgs = encodeJSON(composedMessages)
+ let ttlStr = ttl != nil ? "\(ttl!)" : "default"
+ return "/_send \(ref(type, id, scope: scope)) 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, scope: nil)) \(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, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
+ case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(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, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(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, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl):
+ let ttlStr = ttl != nil ? "\(ttl!)" : "default"
+ return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(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 .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
+ case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)"
+ 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): return "/_create link #\(groupId) \(memberRole)"
+ 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 .apiAddGroupShortLink(groupId): return "/_short 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 .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)"
+ 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, scope: nil)) \(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, scope: nil)) \(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, incognito): return "/_connect \(userId) 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 .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))"
+ case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))"
+ case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
+ case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)"
+ case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))"
+ case let .apiConnectPreparedGroup(groupId, incognito, mc): return "/_connect group #\(groupId) incognito=\(onOff(incognito))\(maybeContent(mc))"
+ 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, scope: nil)) \(chatDeleteMode.cmdString)"
+ case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))"
+ 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): return "/_address \(userId)"
+ case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)"
+ case let .apiShowMyAddress(userId): return "/_show_address \(userId)"
+ case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)"
+ case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
+ case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))"
+ 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 let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))"
+ case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))"
+ case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(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 .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts"
+ 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 .apiAcceptMember: return "apiAcceptMember"
+ case .apiDeleteMemberSupportChat: return "apiDeleteMemberSupportChat"
+ 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 .apiAddGroupShortLink: return "apiAddGroupShortLink"
+ case .apiCreateMemberContact: return "apiCreateMemberContact"
+ case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
+ case .apiAcceptMemberContact: return "apiAcceptMemberContact"
+ 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 .apiPrepareContact: return "apiPrepareContact"
+ case .apiPrepareGroup: return "apiPrepareGroup"
+ case .apiChangePreparedContactUser: return "apiChangePreparedContactUser"
+ case .apiChangePreparedGroupUser: return "apiChangePreparedGroupUser"
+ case .apiConnectPreparedContact: return "apiConnectPreparedContact"
+ case .apiConnectPreparedGroup: return "apiConnectPreparedGroup"
+ 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 .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink"
+ case .apiSetProfileAddress: return "apiSetProfileAddress"
+ case .apiSetAddressSettings: return "apiSetAddressSettings"
+ 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 .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, scope: GroupChatScope?) -> String {
+ "\(type.rawValue)\(id)\(scopeRef(scope: scope))"
+ }
+
+ func scopeRef(scope: GroupChatScope?) -> String {
+ switch (scope) {
+ case .none: ""
+ case let .memberSupport(groupMemberId_):
+ if let groupMemberId = groupMemberId_ {
+ "(_support:\(groupMemberId))"
+ } else {
+ "(_support)"
+ }
+ case .reports:
+ "(reports, prohibited)" // can't use surrogate Reports scope
+ }
+ }
+
+ 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)
+ }
+
+ private func maybeContent(_ mc: MsgContent?) -> String {
+ if case let .text(s) = mc, s.isEmpty {
+ ""
+ } else if let mc {
+ " " + mc.cmdString
+ } else {
+ ""
+ }
+ }
+}
+
+// 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 newPreparedChat(user: UserRef, chat: ChatData)
+ case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
+ case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
+ case sentConfirmation(user: UserRef, connection: PendingContactConnection)
+ case sentInvitation(user: UserRef, connection: PendingContactConnection)
+ case startedConnectionToContact(user: UserRef, contact: Contact)
+ case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo)
+ 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 itemsReadForChat(user: UserRef, chatInfo: ChatInfo)
+ 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, contactRequest: UserContactRequest, contact_: Contact?)
+ 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 .newPreparedChat: "newPreparedChat"
+ case .contactUserChanged: "contactUserChanged"
+ case .groupUserChanged: "groupUserChanged"
+ case .sentConfirmation: "sentConfirmation"
+ case .sentInvitation: "sentInvitation"
+ case .startedConnectionToContact: "startedConnectionToContact"
+ case .startedConnectionToGroup: "startedConnectionToGroup"
+ case .sentInvitationToContact: "sentInvitationToContact"
+ case .contactAlreadyExists: "contactAlreadyExists"
+ case .contactDeleted: "contactDeleted"
+ case .contactConnectionDeleted: "contactConnectionDeleted"
+ case .groupDeletedUser: "groupDeletedUser"
+ case .itemsReadForChat: "itemsReadForChat"
+ 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 .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 .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo))
+ 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, String(describing: contactLink))
+ case let .userContactLinkUpdated(u, contactLink): return withUser(u, String(describing: contactLink))
+ 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 let .contactRequestRejected(u, contactRequest, contact_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\ncontact_: \(String(describing: contact_))")
+ 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 .newPreparedChat(u, chat): return withUser(u, String(describing: chat))
+ case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))")
+ case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))")
+ case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
+ case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
+ case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact))
+ case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ 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 memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ 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, groupLink: GroupLink)
+ case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
+ 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)
+ case memberContactAccepted(user: UserRef, contact: Contact)
+ // 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
+ // 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 .memberAccepted: "memberAccepted"
+ case .memberSupportChatRead: "memberSupportChatRead"
+ case .memberSupportChatDeleted: "memberSupportChatDeleted"
+ 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 .memberContactAccepted: "memberContactAccepted"
+ case .rcvFileAccepted: "rcvFileAccepted"
+ case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
+ case .standaloneFileInfo: "standaloneFileInfo"
+ case .rcvStandaloneFileCreated: "rcvStandaloneFileCreated"
+ case .rcvFileCancelled: "rcvFileCancelled"
+ case .sndFileCancelled: "sndFileCancelled"
+ case .sndStandaloneFileCreated: "sndStandaloneFileCreated"
+ 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 .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ 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, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
+ case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
+ 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 .memberContactAccepted(u, contact): return withUser(u, "contact: \(contact)")
+ 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 .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, chat_: ChatData?)
+ case contactUpdated(user: UserRef, toContact: Contact)
+ case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
+ case subscriptionStatus(subscriptionStatus: SubscriptionStatus, connections: [String])
+ case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo)
+ 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 memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: 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 .subscriptionStatus: "subscriptionStatus"
+ case .chatInfoUpdated: "chatInfoUpdated"
+ 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 .memberAcceptedByOther: "memberAcceptedByOther"
+ 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, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))")
+ 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 .subscriptionStatus(status, conns): return "subscriptionStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
+ case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo))
+ 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 .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\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(contactSLinkData_: ContactShortLinkData?)
+ case ownLink
+ case connecting(contact_: Contact?)
+ case known(contact: Contact)
+}
+
+enum ContactAddressPlan: Decodable, Hashable {
+ case ok(contactSLinkData_: ContactShortLinkData?)
+ case ownLink
+ case connectingConfirmReconnect
+ case connectingProhibit(contact: Contact)
+ case known(contact: Contact)
+ case contactViaAddress(contact: Contact)
+}
+
+enum GroupLinkPlan: Decodable, Hashable {
+ case ok(groupSLinkData_: GroupShortLinkData?)
+ 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 ForwardConfirmation: Decodable, Hashable {
+ case filesNotAccepted(fileIds: [Int64])
+ case filesInProgress(filesCount: Int)
+ case filesMissing(filesCount: Int)
+ case filesFailed(filesCount: Int)
+}
+
+struct UserMsgReceiptSettings: Codable {
+ var enable: Bool
+ var clearOverrides: Bool
+}
+
+protocol SimplexAddress {
+ var connLinkContact: CreatedConnLink { get }
+ var shortLinkDataSet: Bool { get }
+ var shortLinkLargeDataSet: Bool { get }
+}
+
+extension SimplexAddress {
+ var shouldBeUpgraded: Bool {
+ connLinkContact.connShortLink == nil || !shortLinkDataSet || !shortLinkLargeDataSet
+ }
+
+ func shareAddress(short: Bool) {
+ showShareSheet(items: [simplexChatLink(connLinkContact.simplexChatUri(short: short))])
+ }
+}
+
+struct UserContactLink: Decodable, Hashable, SimplexAddress {
+ var connLinkContact: CreatedConnLink
+ var shortLinkDataSet: Bool
+ var shortLinkLargeDataSet: Bool
+ var addressSettings: AddressSettings
+
+ init(_ ccLink: CreatedConnLink) {
+ connLinkContact = ccLink
+ let slDataSet = ccLink.connShortLink != nil
+ shortLinkDataSet = slDataSet
+ shortLinkLargeDataSet = slDataSet
+ addressSettings = AddressSettings(businessAddress: false)
+ }
+}
+
+struct AddressSettings: Codable, Hashable {
+ var businessAddress: Bool
+ var autoAccept: AutoAccept?
+ var autoReply: MsgContent?
+}
+
+struct AutoAccept: Codable, Hashable {
+ var acceptIncognito: Bool
+}
+
+struct GroupLink: Decodable, Hashable, SimplexAddress {
+ var userContactLinkId: Int64
+ var connLinkContact: CreatedConnLink
+ var shortLinkDataSet: Bool
+ var shortLinkLargeDataSet: Bool
+ var groupLinkId: String
+ var acceptMemberRole: GroupMemberRole
+}
+
+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(.unknownCAError)):
+ return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "server test error")
+ default:
+ return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "server test error"), String(describing: testError))
+ }
+ }
+}
+
+struct MigrationFileLinkData: Codable {
+ let networkConfig: NetworkConfig?
+
+ struct NetworkConfig: Codable {
+ let socksProxy: String?
+ let networkProxy: NetworkProxy?
+ let hostMode: HostMode?
+ let requiredHostMode: Bool?
+
+ func transformToPlatformSupported() -> NetworkConfig {
+ return if let hostMode, let requiredHostMode {
+ NetworkConfig(
+ socksProxy: nil,
+ networkProxy: nil,
+ hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode,
+ requiredHostMode: requiredHostMode
+ )
+ } else { self }
+ }
+ }
+
+ func addToLink(link: String) -> String {
+ "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)"
+ }
+
+ static func readFromLink(link: String) -> MigrationFileLinkData? {
+// standaloneFileInfo(link)
+ nil
+ }
+}
+
+struct AppSettings: Codable, Equatable {
+ var networkConfig: NetCfg? = nil
+ var networkProxy: NetworkProxy? = nil
+ var privacyEncryptLocalFiles: Bool? = nil
+ var privacyAskToApproveRelays: Bool? = nil
+ var privacyAcceptImages: Bool? = nil
+ var privacyLinkPreviews: Bool? = nil
+ var privacyShowChatPreviews: Bool? = nil
+ var privacySaveLastDraft: Bool? = nil
+ var privacyProtectScreen: Bool? = nil
+ var privacyMediaBlurRadius: Int? = nil
+ var notificationMode: AppSettingsNotificationMode? = nil
+ var notificationPreviewMode: NotificationPreviewMode? = nil
+ var webrtcPolicyRelay: Bool? = nil
+ var webrtcICEServers: [String]? = nil
+ var confirmRemoteSessions: Bool? = nil
+ var connectRemoteViaMulticast: Bool? = nil
+ var connectRemoteViaMulticastAuto: Bool? = nil
+ var developerTools: Bool? = nil
+ var confirmDBUpgrades: Bool? = nil
+ var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
+ var iosCallKitEnabled: Bool? = nil
+ var iosCallKitCallsInRecents: Bool? = nil
+ var uiProfileImageCornerRadius: Double? = nil
+ var uiChatItemRoundness: Double? = nil
+ var uiChatItemTail: Bool? = nil
+ var uiColorScheme: String? = nil
+ var uiDarkColorScheme: String? = nil
+ var uiCurrentThemeIds: [String: String]? = nil
+ var uiThemes: [ThemeOverrides]? = nil
+ var oneHandUI: Bool? = nil
+ var chatBottomBar: Bool? = nil
+
+ func prepareForExport() -> AppSettings {
+ var empty = AppSettings()
+ let def = AppSettings.defaults
+ if networkConfig != def.networkConfig { empty.networkConfig = networkConfig }
+ if networkProxy != def.networkProxy { empty.networkProxy = networkProxy }
+ if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
+ if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
+ if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
+ if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
+ if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
+ if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft }
+ if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen }
+ if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius }
+ if notificationMode != def.notificationMode { empty.notificationMode = notificationMode }
+ if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode }
+ if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay }
+ if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers }
+ if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions }
+ if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast }
+ if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto }
+ if developerTools != def.developerTools { empty.developerTools = developerTools }
+ if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades }
+ if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
+ if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
+ if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
+ if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius }
+ if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness }
+ if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail }
+ if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme }
+ if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme }
+ if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds }
+ if uiThemes != def.uiThemes { empty.uiThemes = uiThemes }
+ if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
+ if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar }
+ return empty
+ }
+
+ static var defaults: AppSettings {
+ AppSettings (
+ networkConfig: NetCfg.defaults,
+ networkProxy: NetworkProxy.def,
+ privacyEncryptLocalFiles: true,
+ privacyAskToApproveRelays: true,
+ privacyAcceptImages: true,
+ privacyLinkPreviews: true,
+ privacyShowChatPreviews: true,
+ privacySaveLastDraft: true,
+ privacyProtectScreen: false,
+ privacyMediaBlurRadius: 0,
+ notificationMode: AppSettingsNotificationMode.instant,
+ notificationPreviewMode: NotificationPreviewMode.message,
+ webrtcPolicyRelay: true,
+ webrtcICEServers: [],
+ confirmRemoteSessions: false,
+ connectRemoteViaMulticast: true,
+ connectRemoteViaMulticastAuto: true,
+ developerTools: false,
+ confirmDBUpgrades: false,
+ androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
+ iosCallKitEnabled: true,
+ iosCallKitCallsInRecents: false,
+ uiProfileImageCornerRadius: 22.5,
+ uiChatItemRoundness: 0.75,
+ uiChatItemTail: true,
+ uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME,
+ uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName,
+ uiCurrentThemeIds: nil as [String: String]?,
+ uiThemes: nil as [ThemeOverrides]?,
+ oneHandUI: true,
+ chatBottomBar: true
+ )
+ }
+}
+
+enum AppSettingsNotificationMode: String, Codable {
+ case off
+ case periodic
+ case instant
+
+ func toNotificationsMode() -> NotificationsMode {
+ switch self {
+ case .instant: .instant
+ case .periodic: .periodic
+ case .off: .off
+ }
+ }
+
+ static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode {
+ switch mode {
+ case .instant: .instant
+ case .periodic: .periodic
+ case .off: .off
+ }
+ }
+}
+
+//enum NotificationPreviewMode: Codable {
+// case hidden
+// case contact
+// case message
+//}
+
+enum AppSettingsLockScreenCalls: String, Codable {
+ case disable
+ case show
+ case accept
+}
+
+struct UserNetworkInfo: Codable, Equatable {
+ let networkType: UserNetworkType
+ let online: Bool
+}
+
+enum UserNetworkType: String, Codable {
+ case none
+ case cellular
+ case wifi
+ case ethernet
+ case other
+
+ var text: LocalizedStringKey {
+ switch self {
+ case .none: "No network connection"
+ case .cellular: "Cellular"
+ case .wifi: "WiFi"
+ case .ethernet: "Wired ethernet"
+ case .other: "Other"
+ }
+ }
+}
+
+struct RcvMsgInfo: Codable {
+ var msgId: Int64
+ var msgDeliveryId: Int64
+ var msgDeliveryStatus: String
+ var agentMsgId: Int64
+ var agentMsgMeta: String
+}
+
+struct ServerQueueInfo: Codable {
+ var server: String
+ var rcvId: String
+ var sndId: String
+ var ntfId: String?
+ var status: String
+ var info: QueueInfo
+}
+
+struct QueueInfo: Codable {
+ var qiSnd: Bool
+ var qiNtf: Bool
+ var qiSub: QSub?
+ var qiSize: Int
+ var qiMsg: MsgInfo?
+}
+
+struct QSub: Codable {
+ var qSubThread: QSubThread
+ var qDelivered: String?
+}
+
+enum QSubThread: String, Codable {
+ case noSub
+ case subPending
+ case subThread
+ case prohibitSub
+}
+
+struct MsgInfo: Codable {
+ var msgId: String
+ var msgTs: Date
+ var msgType: MsgType
+}
+
+enum MsgType: String, Codable {
+ case message
+ case quota
+}
+
+struct PresentedServersSummary: Codable {
+ var statsStartedAt: Date
+ var allUsersSMP: SMPServersSummary
+ var allUsersXFTP: XFTPServersSummary
+ var currentUserSMP: SMPServersSummary
+ var currentUserXFTP: XFTPServersSummary
+}
+
+struct SMPServersSummary: Codable {
+ var smpTotals: SMPTotals
+ var currentlyUsedSMPServers: [SMPServerSummary]
+ var previouslyUsedSMPServers: [SMPServerSummary]
+ var onlyProxiedSMPServers: [SMPServerSummary]
+}
+
+struct SMPTotals: Codable {
+ var sessions: ServerSessions
+ var subs: SMPServerSubs
+ var stats: AgentSMPServerStatsData
+}
+
+struct SMPServerSummary: Codable, Identifiable {
+ var smpServer: String
+ var known: Bool?
+ var sessions: ServerSessions?
+ var subs: SMPServerSubs?
+ var stats: AgentSMPServerStatsData?
+
+ var id: String { smpServer }
+
+ var hasSubs: Bool { subs != nil }
+
+ var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions }
+
+ var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs }
+}
+
+struct ServerSessions: Codable {
+ var ssConnected: Int
+ var ssErrors: Int
+ var ssConnecting: Int
+
+ static var newServerSessions = ServerSessions(
+ ssConnected: 0,
+ ssErrors: 0,
+ ssConnecting: 0
+ )
+
+ var hasSess: Bool { ssConnected > 0 }
+}
+
+struct SMPServerSubs: Codable {
+ var ssActive: Int
+ var ssPending: Int
+
+ static var newSMPServerSubs = SMPServerSubs(
+ ssActive: 0,
+ ssPending: 0
+ )
+
+ var total: Int { ssActive + ssPending }
+
+ var shareOfActive: Double {
+ guard total != 0 else { return 0.0 }
+ return Double(ssActive) / Double(total)
+ }
+}
+
+struct AgentSMPServerStatsData: Codable {
+ var _sentDirect: Int
+ var _sentViaProxy: Int
+ var _sentProxied: Int
+ var _sentDirectAttempts: Int
+ var _sentViaProxyAttempts: Int
+ var _sentProxiedAttempts: Int
+ var _sentAuthErrs: Int
+ var _sentQuotaErrs: Int
+ var _sentExpiredErrs: Int
+ var _sentOtherErrs: Int
+ var _recvMsgs: Int
+ var _recvDuplicates: Int
+ var _recvCryptoErrs: Int
+ var _recvErrs: Int
+ var _ackMsgs: Int
+ var _ackAttempts: Int
+ var _ackNoMsgErrs: Int
+ var _ackOtherErrs: Int
+ var _connCreated: Int
+ var _connSecured: Int
+ var _connCompleted: Int
+ var _connDeleted: Int
+ var _connDelAttempts: Int
+ var _connDelErrs: Int
+ var _connSubscribed: Int
+ var _connSubAttempts: Int
+ var _connSubIgnored: Int
+ var _connSubErrs: Int
+ var _ntfKey: Int
+ var _ntfKeyAttempts: Int
+ var _ntfKeyDeleted: Int
+ var _ntfKeyDeleteAttempts: Int
+}
+
+struct XFTPServersSummary: Codable {
+ var xftpTotals: XFTPTotals
+ var currentlyUsedXFTPServers: [XFTPServerSummary]
+ var previouslyUsedXFTPServers: [XFTPServerSummary]
+}
+
+struct XFTPTotals: Codable {
+ var sessions: ServerSessions
+ var stats: AgentXFTPServerStatsData
+}
+
+struct XFTPServerSummary: Codable, Identifiable {
+ var xftpServer: String
+ var known: Bool?
+ var sessions: ServerSessions?
+ var stats: AgentXFTPServerStatsData?
+ var rcvInProgress: Bool
+ var sndInProgress: Bool
+ var delInProgress: Bool
+
+ var id: String { xftpServer }
+}
+
+struct AgentXFTPServerStatsData: Codable {
+ var _uploads: Int
+ var _uploadsSize: Int64
+ var _uploadAttempts: Int
+ var _uploadErrs: Int
+ var _downloads: Int
+ var _downloadsSize: Int64
+ var _downloadAttempts: Int
+ var _downloadAuthErrs: Int
+ var _downloadErrs: Int
+ var _deletions: Int
+ var _deleteAttempts: Int
+ var _deleteErrs: Int
+}
+
+struct AgentNtfServerStatsData: Codable {
+ var _ntfCreated: Int
+ var _ntfCreateAttempts: Int
+ var _ntfChecked: Int
+ var _ntfCheckAttempts: Int
+ var _ntfDeleted: Int
+ var _ntfDelAttempts: Int
+}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 95cebcde10..f1f4e686bd 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,8 +52,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
+// analogue for SecondaryContextFilter in Kotlin
+enum SecondaryItemsModelFilter {
+ case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo)
+ case msgContentTagContext(contentTag: MsgContentTag)
+
+ func descr() -> String {
+ switch self {
+ case let .groupChatScopeContext(groupScopeInfo):
+ return "groupChatScopeContext \(groupScopeInfo.toChatScope())"
+ case let .msgContentTagContext(contentTag):
+ return "msgContentTagContext \(contentTag.rawValue)"
+ }
+ }
+}
+
+// analogue for ChatsContext in Kotlin
class ItemsModel: ObservableObject {
- static let shared = ItemsModel()
+ static let shared = ItemsModel(secondaryIMFilter: nil)
+ public var secondaryIMFilter: SecondaryItemsModelFilter?
+ public var preloadState = PreloadState()
private let publisher = ObservableObjectPublisher()
private var bag = Set()
var reversedChatItems: [ChatItem] = [] {
@@ -53,61 +80,113 @@ class ItemsModel: ObservableObject {
var itemAdded = false {
willSet { publisher.send() }
}
-
+
+ let chatState = ActiveChatState()
+
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
- @Published var showLoadingProgress = false
+ @Published var showLoadingProgress: ChatId? = nil
- init() {
+ private var navigationTimeoutTask: Task? = nil
+ private var loadChatTask: Task? = nil
+
+ var lastItemsLoaded: Bool {
+ chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
+ }
+
+ init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) {
+ self.secondaryIMFilter = secondaryIMFilter
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
+ static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) {
+ let im = ItemsModel(secondaryIMFilter: chatFilter)
+ ChatModel.shared.secondaryIM = im
+ im.loadOpenChat(chatId, willNavigate: willNavigate)
+ }
+
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
- let navigationTimeout = Task {
+ navigationTimeoutTask?.cancel()
+ loadChatTask?.cancel()
+ navigationTimeoutTask = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
- willNavigate()
ChatModel.shared.chatId = chatId
+ willNavigate()
}
} catch {}
}
- let progressTimeout = Task {
- do {
- try await Task.sleep(nanoseconds: 1500_000000)
- await MainActor.run { showLoadingProgress = true }
- } catch {}
- }
- Task {
- if let chat = ChatModel.shared.getChat(chatId) {
- await MainActor.run { self.isLoading = true }
-// try? await Task.sleep(nanoseconds: 5000_000000)
- await loadChat(chat: chat)
- navigationTimeout.cancel()
- progressTimeout.cancel()
+ loadChatTask = Task {
+ await MainActor.run { self.isLoading = true }
+// try? await Task.sleep(nanoseconds: 1000_000000)
+ await loadChat(chatId: chatId, im: self)
+ if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
- self.showLoadingProgress = false
- willNavigate()
- ChatModel.shared.chatId = chatId
+ self.showLoadingProgress = nil
}
}
}
}
+
+ func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
+ navigationTimeoutTask?.cancel()
+ loadChatTask?.cancel()
+ loadChatTask = Task {
+ // try? await Task.sleep(nanoseconds: 1000_000000)
+ await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
+ if !Task.isCancelled {
+ await MainActor.run {
+ if openAroundItemId == nil {
+ ChatModel.shared.chatId = chatId
+ }
+ }
+ }
+ }
+ }
+
+ public var contentTag: MsgContentTag? {
+ switch secondaryIMFilter {
+ case nil: nil
+ case .groupChatScopeContext: nil
+ case let .msgContentTagContext(contentTag): contentTag
+ }
+ }
+
+ public var groupScopeInfo: GroupChatScopeInfo? {
+ switch secondaryIMFilter {
+ case nil: nil
+ case let .groupChatScopeContext(scopeInfo): scopeInfo
+ case .msgContentTagContext: nil
+ }
+ }
+}
+
+class PreloadState {
+ var prevFirstVisible: Int64 = Int64.min
+ var prevItemsCount: Int = 0
+ var preloading: Bool = false
+
+ func clear() {
+ prevFirstVisible = Int64.min
+ prevItemsCount = 0
+ preloading = false
+ }
}
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] = [:]
@@ -124,11 +203,9 @@ class ChatTagsModel: ObservableObject {
}
}
}
- if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 {
- activeFilter = nil
- }
presetTags = newPresetTags
unreadTags = newUnreadTags
+ clearActiveChatFilterIfNeeded()
}
func updateChatFavorite(favorite: Bool, wasFavorite: Bool) {
@@ -137,9 +214,7 @@ class ChatTagsModel: ObservableObject {
presetTags[.favorites] = (count ?? 0) + 1
} else if !favorite && wasFavorite, let count {
presetTags[.favorites] = max(0, count - 1)
- if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 {
- activeFilter = nil
- }
+ clearActiveChatFilterIfNeeded()
}
}
@@ -163,14 +238,15 @@ class ChatTagsModel: ObservableObject {
}
}
}
+ 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
@@ -193,30 +269,17 @@ class ChatTagsModel: ObservableObject {
func changeGroupReportsTag(_ by: Int = 0) {
if by == 0 { return }
- presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by
- }
-}
-
-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
- }
+ presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by)
+ clearActiveChatFilterIfNeeded()
}
- func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
- if let conn = contact.activeConn {
- networkStatuses[conn.agentConnId] ?? .unknown
- } else {
- .unknown
+ 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 }
}
}
@@ -228,6 +291,41 @@ class ChatItemDummyModel: ObservableObject {
func sendUpdate() { objectWillChange.send() }
}
+class ConnectProgressManager: ObservableObject {
+ @Published private var connectInProgress: String? = nil
+ @Published private var connectProgressByTimeout: Bool = false
+ private var onCancel: (() -> Void)?
+
+ static let shared = ConnectProgressManager()
+
+ func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) {
+ connectInProgress = text
+ self.onCancel = onCancel
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.connectProgressByTimeout = self.connectInProgress != nil
+ }
+ }
+
+ func stopConnectProgress() {
+ connectInProgress = nil
+ onCancel = nil
+ connectProgressByTimeout = false
+ }
+
+ func cancelConnectProgress() {
+ onCancel?()
+ stopConnectProgress()
+ }
+
+ var showConnectProgress: String? {
+ connectProgressByTimeout ? connectInProgress : nil
+ }
+
+ var isInProgress: Bool {
+ connectInProgress != nil
+ }
+}
+
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -253,7 +351,9 @@ final class ChatModel: ObservableObject {
@Published var deletedChats: Set = []
// current chat
@Published var chatId: String?
- var chatItemStatuses: Dictionary = [:]
+ @Published var chatAgentConnId: String?
+ @Published var chatSubStatus: SubscriptionStatus?
+ @Published var openAroundItemId: ChatItem.ID? = nil
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list
@@ -264,9 +364,11 @@ final class ChatModel: ObservableObject {
@Published var userAddress: UserContactLink?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
+ @Published var appOpenUrlLater: URL?
@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?
@@ -301,6 +403,10 @@ final class ChatModel: ObservableObject {
let im = ItemsModel.shared
+ // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat
+ @Published var secondaryIM: ItemsModel? = nil
+ @Published var secondaryPendingInviteeChatOpened = false
+
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -313,6 +419,10 @@ final class ChatModel: ObservableObject {
remoteCtrlSession?.active ?? false
}
+ var addressShortLinkDataSet: Bool {
+ userAddress?.shortLinkDataSet ?? true
+ }
+
func getUser(_ userId: Int64) -> User? {
currentUser?.userId == userId
? currentUser
@@ -358,7 +468,7 @@ final class ChatModel: ObservableObject {
func getGroupChat(_ groupId: Int64) -> Chat? {
chats.first { chat in
- if case let .group(groupInfo) = chat.chatInfo {
+ if case let .group(groupInfo, _) = chat.chatInfo {
return groupInfo.groupId == groupId
} else {
return false
@@ -411,7 +521,11 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
- chats[i].chatInfo = cInfo
+ if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil {
+ chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil)
+ } else {
+ chats[i].chatInfo = cInfo
+ }
chats[i].created = Date.now
}
}
@@ -433,7 +547,7 @@ final class ChatModel: ObservableObject {
}
func updateGroup(_ groupInfo: GroupInfo) {
- updateChat(.group(groupInfo: groupInfo))
+ updateChat(.group(groupInfo: groupInfo, groupChatScope: nil))
}
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
@@ -465,8 +579,15 @@ final class ChatModel: ObservableObject {
}
}
- func updateChats(_ newChats: [ChatData]) {
- chats = newChats.map { Chat($0) }
+ func updateChats(_ newChats: [ChatData], keepingChatId: String? = nil) {
+ if let keepingChatId,
+ let chatToKeep = getChat(keepingChatId),
+ let i = newChats.firstIndex(where: { $0.id == keepingChatId }) {
+ let remainingNewChats = Array(newChats[..= currentPreviewItem.meta.itemTs {
- [cItem]
+ // update preview
+ if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false {
+ chats[i].chatItems = switch cInfo {
+ case .group:
+ if let currentPreviewItem = chats[i].chatItems.first {
+ if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
+ [cItem]
+ } else {
+ [currentPreviewItem]
+ }
} else {
- [currentPreviewItem]
+ [cItem]
}
- } else {
+ default:
[cItem]
}
- default:
- [cItem]
- }
- if case .rcvNew = cItem.meta.itemStatus {
- unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
+ if case .rcvNew = cItem.meta.itemStatus {
+ unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
+ }
}
+ // pop chat
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
- addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ if cInfo.groupChatScope() == nil {
+ addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ } else {
+ addChat(Chat(chatInfo: cInfo, chatItems: []))
+ }
}
- // add to current chat
- if chatId == cInfo.id {
- _ = _upsertChatItem(cInfo, cItem)
+ // add to current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ _ = _upsertChatItem(ciIM, cInfo, cItem)
+ }
+ }
+
+ func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? {
+ let cInfoScope = cInfo.groupChatScope()
+ return if let cInfoScope = cInfoScope {
+ switch (cInfoScope, secondaryIM?.secondaryIMFilter) {
+ case let (.memberSupport, .some(.groupChatScopeContext(groupScopeInfo))):
+ // Chat with member or Chat with admins opened (secondaryIM has .groupChatScopeContext filter), cInfo has matching scope
+ (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil
+
+ case let (.memberSupport, .some(.msgContentTagContext(contentTag))):
+ // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process event (cInfo has proper .memberSupport scope)
+ (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil
+
+ case let (.reports, .some(.msgContentTagContext(contentTag))):
+ // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process user action (cInfo has surrogate .reports scope)
+ (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil
+ default:
+ nil
+ }
+ } else {
+ cInfo.id == chatId ? im : nil
}
}
func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
- // update previews
- var res: Bool
- if let chat = getChat(cInfo.id) {
- if let pItem = chat.chatItems.last {
- if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
+ // update chat list
+ var itemAdded: Bool = false
+ if cInfo.groupChatScope() == nil {
+ if let chat = getChat(cInfo.id) {
+ if let pItem = chat.chatItems.last {
+ if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
+ chat.chatItems = [cItem]
+ }
+ } else {
chat.chatItems = [cItem]
}
} else {
- chat.chatItems = [cItem]
+ addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ itemAdded = true
+ }
+ if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
+ VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
- res = false
- } else {
- addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
- res = true
}
- if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
- VoiceItemState.stopVoiceInChatView(cInfo, cItem)
+ // update current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ itemAdded = _upsertChatItem(ciIM, cInfo, cItem)
}
- // update current chat
- return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
+ return itemAdded
}
- private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
- if let i = getChatItemIndex(cItem) {
- _updateChatItem(at: i, with: cItem)
- ChatItemDummyModel.shared.sendUpdate()
+ private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
+ if let i = getChatItemIndex(ciIM, cItem) {
+ let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus
+ let newStatus = cItem.meta.itemStatus
+ var ci = cItem
+ if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) {
+ ci.meta.itemStatus = oldStatus
+ }
+ _updateChatItem(ciIM: ciIM, at: i, with: ci)
+ ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this
return false
} else {
- var ci = cItem
- if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
- ci.meta.itemStatus = status
- }
- im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
- im.itemAdded = true
+ ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
+ ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0)
+ ciIM.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -559,45 +723,82 @@ final class ChatModel: ObservableObject {
}
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
- if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
+ if let ciIM = getCIItemsModel(cInfo, cItem),
+ let i = getChatItemIndex(ciIM, cItem) {
withConditionalAnimation {
- _updateChatItem(at: i, with: cItem)
+ _updateChatItem(ciIM: ciIM, at: i, with: cItem)
}
- } else if let status = status {
- chatItemStatuses.updateValue(status, forKey: cItem.id)
}
}
- private func _updateChatItem(at i: Int, with cItem: ChatItem) {
- im.reversedChatItems[i] = cItem
- im.reversedChatItems[i].viewTimestamp = .now
+ private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) {
+ ciIM.reversedChatItems[i] = cItem
+ ciIM.reversedChatItems[i].viewTimestamp = .now
}
- func getChatItemIndex(_ cItem: ChatItem) -> Int? {
- im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
+ func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? {
+ ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
- if cItem.isRcvNew {
- unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
- }
- // update previews
- if let chat = getChat(cInfo.id) {
- if let pItem = chat.chatItems.last, pItem.id == cItem.id {
- chat.chatItems = [ChatItem.deletedItemDummy()]
+ // update chat list
+ if cInfo.groupChatScope() == nil {
+ if cItem.isRcvNew {
+ unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
+ }
+ // update previews
+ if let chat = getChat(cInfo.id) {
+ if let pItem = chat.chatItems.last, pItem.id == cItem.id {
+ chat.chatItems = [ChatItem.deletedItemDummy()]
+ }
}
}
- // remove from current chat
- if chatId == cInfo.id {
- if let i = getChatItemIndex(cItem) {
- _ = withAnimation {
- im.reversedChatItems.remove(at: i)
+ // remove from current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ if let i = getChatItemIndex(ciIM, cItem) {
+ withAnimation {
+ let item = ciIM.reversedChatItems.remove(at: i)
+ ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
+ func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
+ if chatId == groupInfo.id {
+ for i in 0.. 0,
+ let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
+ chat.chatItems = [updatedItem]
+ }
+
+ func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
+ let newContent: CIContent
+ if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
+ newContent = .sndModerated
+ } else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
+ newContent = .rcvModerated
+ } else {
+ return nil
+ }
+ var updatedItem = item
+ updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
+ if groupInfo.fullGroupPreferences.fullDelete.on {
+ updatedItem.content = newContent
+ }
+ if item.isActiveReport {
+ decreaseGroupReportsCounter(groupInfo.id)
+ }
+ return updatedItem
+ }
+ }
+
func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
@@ -640,6 +841,7 @@ final class ChatModel: ObservableObject {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
im.reversedChatItems.insert(cItem, at: 0)
+ im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
im.itemAdded = true
}
return cItem
@@ -659,63 +861,23 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true
}
- func markChatItemsRead(_ cInfo: ChatInfo) {
+ func markAllChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
- self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
- self.updateFloatingButtons(unreadCount: 0)
+ self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
}
// update current chat
if chatId == cInfo.id {
- markCurrentChatRead()
- }
- }
-
- private func markCurrentChatRead(fromIndex i: Int = 0) {
- var j = i
- while j < im.reversedChatItems.count {
- markChatItemRead_(j)
- j += 1
- }
- }
-
- private func updateFloatingButtons(unreadCount: Int) {
- let fbm = ChatView.FloatingButtonModel.shared
- fbm.totalUnread = unreadCount
- fbm.objectWillChange.send()
- }
-
- func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
- if let cItem = aboveItem {
- if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
- markCurrentChatRead(fromIndex: i)
- _updateChat(cInfo.id) { chat in
- var unreadBelow = 0
- var j = i - 1
- while j >= 0 {
- if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
- unreadBelow += 1
- }
- j -= 1
- }
- // update preview
- let markedCount = chat.chatStats.unreadCount - unreadBelow
- if markedCount > 0 {
- let wasUnread = chat.unreadTag
- chat.chatStats.unreadCount -= markedCount
- ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
- self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
- self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
- }
- }
+ var i = 0
+ while i < im.reversedChatItems.count {
+ markChatItemRead_(chatIM, 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
@@ -727,7 +889,7 @@ final class ChatModel: ObservableObject {
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()
@@ -735,20 +897,28 @@ final class ChatModel: ObservableObject {
}
// clear current chat
if chatId == cInfo.id {
- chatItemStatuses = [:]
im.reversedChatItems = []
+ im.chatState.clear()
}
}
- func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
+ func markChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
if self.chatId == cInfo.id {
- for itemId in itemIds {
- if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
- markChatItemRead_(i)
+ var unreadItemIds: Set = []
+ var i = 0
+ var ids = Set(itemIds)
+ while i < chatIM.reversedChatItems.count && !ids.isEmpty {
+ let item = chatIM.reversedChatItems[i]
+ if ids.contains(item.id) && item.isRcvNew {
+ markChatItemRead_(chatIM, i)
+ unreadItemIds.insert(item.id)
+ ids.remove(item.id)
}
+ i += 1
}
+ chatIM.chatState.itemsRead(unreadItemIds, chatIM.reversedChatItems.reversed())
}
- self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
+ self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
private let unreadCollector = UnreadCollector()
@@ -756,16 +926,16 @@ final class ChatModel: ObservableObject {
class UnreadCollector {
private let subject = PassthroughSubject()
private var bag = Set()
- private var unreadCounts: [ChatId: Int] = [:]
+ private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
- for (chatId, count) in self.unreadCounts {
- if let i = m.getChatIndex(chatId) {
- m.changeUnreadCounter(i, by: count)
+ for (chatId, (unread, mentions)) in self.unreadCounts {
+ if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) {
+ m.changeUnreadCounter(i, by: unread, unreadMentions: mentions)
}
}
self.unreadCounts = [:]
@@ -773,17 +943,15 @@ final class ChatModel: ObservableObject {
.store(in: &bag)
}
- func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
- if chatId == ChatModel.shared.chatId {
- ChatView.FloatingButtonModel.shared.totalUnread += count
- }
- self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
+ func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
+ let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
+ self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
subject.send()
}
}
let popChatCollector = PopChatCollector()
-
+
class PopChatCollector {
private let subject = PassthroughSubject()
private var bag = Set()
@@ -796,7 +964,7 @@ final class ChatModel: ObservableObject {
.sink { self.popCollectedChats() }
.store(in: &bag)
}
-
+
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
@@ -807,7 +975,7 @@ final class ChatModel: ObservableObject {
subject.send()
}
}
-
+
func clear() {
chatsToPop = [:]
}
@@ -844,20 +1012,22 @@ final class ChatModel: ObservableObject {
}
}
- private func markChatItemRead_(_ i: Int) {
- let meta = im.reversedChatItems[i].meta
+ private func markChatItemRead_(_ chatIM: ItemsModel, _ i: Int) {
+ let meta = chatIM.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
- im.reversedChatItems[i].meta.itemStatus = .rcvRead
- im.reversedChatItems[i].viewTimestamp = .now
+ chatIM.reversedChatItems[i].meta.itemStatus = .rcvRead
+ chatIM.reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
- im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
+ chatIM.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
- func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
+ func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) {
let wasUnread = chats[chatIndex].unreadTag
- chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
+ 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)
}
@@ -866,6 +1036,13 @@ final class ChatModel: ObservableObject {
changeUnreadCounter(user: user, by: 1)
}
+ func decreaseUnreadCounter(user: any UserLike, chat: Chat) {
+ let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions
+ ? chat.chatStats.unreadMentions
+ : chat.chatStats.unreadCount
+ decreaseUnreadCounter(user: user, by: by)
+ }
+
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by)
}
@@ -878,8 +1055,20 @@ final class ChatModel: ObservableObject {
}
func totalUnreadCountForAllUsers() -> Int {
- chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
- users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
+ var unread: Int = 0
+ for chat in chats {
+ switch chat.chatInfo.chatSettings?.enableNtfs {
+ case .all: unread += chat.chatStats.unreadCount
+ case .mentions: unread += chat.chatStats.unreadMentions
+ default: ()
+ }
+ }
+ for u in users {
+ if !u.user.activeUser {
+ unread += u.unreadCount
+ }
+ }
+ return unread
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
@@ -887,7 +1076,7 @@ final class ChatModel: ObservableObject {
}
func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
- changeGroupReportsCounter(chatId, -1)
+ changeGroupReportsCounter(chatId, -by)
}
private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
@@ -908,7 +1097,7 @@ final class ChatModel: ObservableObject {
var count = 0
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
- var i = getChatItemIndex(chatItem) {
+ var i = getChatItemIndex(im, chatItem) { // TODO [knocking] review: use getCIItemsModel?
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
@@ -924,7 +1113,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) {
+ if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel?
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
@@ -948,12 +1137,17 @@ final class ChatModel: ObservableObject {
// returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) {
+ let items = im.reversedChatItems
var prevMember: GroupMember? = nil
var memberIds: Set = []
for i in range {
- if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
- if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
- memberIds.insert(m.groupMemberId)
+ if i < items.count {
+ if case let .groupRcv(m) = items[i].chatDir {
+ if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
+ memberIds.insert(m.groupMemberId)
+ }
+ } else {
+ logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
}
}
return (prevMember, memberIds.count)
@@ -1030,7 +1224,7 @@ final class ChatModel: ObservableObject {
func removeWallpaperFilesFromChat(_ chat: Chat) {
if case let .direct(contact) = chat.chatInfo {
removeWallpaperFilesFromTheme(contact.uiThemes)
- } else if case let .group(groupInfo) = chat.chatInfo {
+ } else if case let .group(groupInfo, _) = chat.chatInfo {
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
}
@@ -1051,7 +1245,6 @@ struct ShowingInvitation {
}
struct NTFContactRequest {
- var incognito: Bool
var chatId: String
}
@@ -1082,35 +1275,30 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
)
}
- var userCanSend: Bool {
- switch chatInfo {
- case .direct: return true
- case let .group(groupInfo):
- let m = groupInfo.membership
- return m.memberActive && m.memberRole >= .member
- case .local:
- return true
- default: return false
- }
- }
-
- var userIsObserver: Bool {
- switch chatInfo {
- case let .group(groupInfo):
- let m = groupInfo.membership
- return m.memberActive && m.memberRole == .observer
- default: return false
- }
- }
-
var unreadTag: Bool {
- chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat)
+ switch chatInfo.chatSettings?.enableNtfs {
+ case .all: chatStats.unreadChat || chatStats.unreadCount > 0
+ case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0
+ default: chatStats.unreadChat
+ }
}
-
+
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
+ var supportUnreadCount: Int {
+ switch chatInfo {
+ case let .group(groupInfo, _):
+ if groupInfo.canModerate {
+ return groupInfo.membersRequireAttention
+ } else {
+ return groupInfo.membership.supportChat?.unread ?? 0
+ }
+ default: return 0
+ }
+ }
+
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index 6c33031eeb..79f4ef2f09 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -12,7 +12,6 @@ import UIKit
import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
-let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
@@ -59,13 +58,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
- if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
+ if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
let chatId = content.userInfo["chatId"] as? String {
- let incognito = action == ntfActionAcceptContactIncognito
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
- Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} else {
- chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
+ chatModel.ntfContactRequest = NTFContactRequest(chatId: chatId)
}
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
@@ -161,10 +159,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
identifier: ntfActionAcceptContact,
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
options: .foreground
- ), UNNotificationAction(
- identifier: ntfActionAcceptContactIncognito,
- title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
- options: .foreground
)
],
intentIdentifiers: [],
@@ -248,7 +242,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
- if cInfo.ntfsEnabled {
+ if cInfo.ntfsEnabled(chatItem: cItem) {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 2380f79d59..5a042a6252 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -11,40 +11,40 @@ import UIKit
import Dispatch
import BackgroundTasks
import SwiftUI
-import SimpleXChat
+@preconcurrency import SimpleXChat
private var chatController: chat_ctrl?
-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)
}
}
}
@@ -86,18 +86,24 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r
}
-func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
+@inline(__always)
+func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R {
+ let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
+ return try apiResult(res)
+}
+
+func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)")
}
let start = Date.now
- let resp = bgTask
- ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
- : sendSimpleXCmd(cmd, ctrl)
+ let resp: APIResult = bgTask
+ ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl, retryNum: retryNum) }
+ : sendSimpleXCmd(cmd, ctrl, retryNum: retryNum)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
- if case let .response(_, json) = resp {
- logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
+ if case let .invalid(_, json) = resp {
+ logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))")
}
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
@@ -106,35 +112,143 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp
}
-func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
- await withCheckedContinuation { cont in
- cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
+@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)
+}
+
+func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? {
+ let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum)
+ if inProgress == nil || inProgress?.boxedValue == true,
+ case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) {
+ return await withCheckedContinuation { cont in
+ showRetryAlert(
+ alert,
+ onCancel: { _ in
+ cont.resume(returning: nil)
+ },
+ onRetry: {
+ let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1)
+ cont.resume(returning: r1)
+ }
+ )
+ }
+ } else {
+ return r
}
}
-func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? {
+@inline(__always)
+func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escaping (UIAlertAction) -> Void, onRetry: @escaping () async -> Void) {
+ DispatchQueue.main.async {
+ showAlert(
+ alert.title,
+ message: alert.message,
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .cancel,
+ handler: onCancel
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Retry", comment: "alert action"),
+ style: .default,
+ handler: { _ in Task(operation: onRetry) }
+ )
+ ]}
+ )
+ }
+}
+
+func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: String)? {
+ switch e {
+ case let .errorAgent(.BROKER(addr, .TIMEOUT)): (
+ title: NSLocalizedString("Connection timeout", comment: "alert title"),
+ message: serverErrorAlertMessage(addr)
+ )
+ case let .errorAgent(.BROKER(addr, .NETWORK(.unknownCAError))): nil
+ case let .errorAgent(.BROKER(addr, .NETWORK)): (
+ title: NSLocalizedString("Connection error", comment: "alert title"),
+ message: serverErrorAlertMessage(addr)
+ )
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.TIMEOUT)))): (
+ title: NSLocalizedString("Private routing timeout", comment: "alert title"),
+ message: proxyErrorAlertMessage(serverAddress)
+ )
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK(.unknownCAError))))): nil
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK)))): (
+ title: NSLocalizedString("Private routing error", comment: "alert title"),
+ message: proxyErrorAlertMessage(serverAddress)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.TIMEOUT))))): (
+ title: NSLocalizedString("Private routing timeout", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK(.unknownCAError)))))): nil
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK))))): (
+ title: NSLocalizedString("Private routing error", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.NO_SESSION)))): (
+ title: NSLocalizedString("No private routing session", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ default: nil
+ }
+}
+
+func serverErrorAlertMessage(_ addr: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Please check your network connection with %@ and try again.", comment: "alert message"), serverHostname(addr))
+}
+
+func proxyErrorAlertMessage(_ addr: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Error connecting to forwarding server %@. Please try later.", comment: "alert message"), serverHostname(addr))
+}
+
+func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer))
+}
+
+@inline(__always)
+func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult {
await withCheckedContinuation { cont in
- _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
- let resp = recvSimpleXMsg(ctrl)
- cont.resume(returning: resp)
- return resp
+ cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, retryNum: retryNum, log: log))
+ }
+}
+
+@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) { () -> APIResult? in
+ let evt: APIResult? = recvSimpleXMsg(ctrl)
+ cont.resume(returning: evt)
+ return evt
}
}
}
func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
- let r = chatSendCmdSync(.showActiveUser, ctrl)
+ let r: APIResult = chatApiSendCmdSync(.showActiveUser, ctrl: ctrl)
switch r {
- case let .activeUser(user): return user
- case .chatCmdError(_, .error(.noActiveUser)): return nil
- default: throw r
+ case let .result(.activeUser(user)): return user
+ case .error(.error(.noActiveUser)): return nil
+ default: throw r.unexpected
}
}
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
- let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
+ let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl)
if case let .activeUser(user) = r { return user }
- throw r
+ throw r.unexpected
}
func listUsers() throws -> [UserInfo] {
@@ -145,41 +259,39 @@ 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 apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async throws {
+ try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable))
}
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
@@ -199,90 +311,88 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User {
}
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
- let r = await chatSendCmd(cmd)
+ let r: ChatResponse1 = try await chatSendCmd(cmd)
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
- throw r
+ throw r.unexpected
}
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws {
- let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
- let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
+ let r: ChatResponse0 = try chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl: ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
- default: throw r
+ default: throw r.unexpected
}
}
func apiCheckChatRunning() throws -> Bool {
- let r = chatSendCmdSync(.checkChatRunning)
+ let r: ChatResponse0 = try chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
- default: throw r
+ default: throw r.unexpected
}
}
func apiStopChat() async throws {
- let r = await chatSendCmd(.apiStopChat)
+ let r: ChatResponse0 = try await chatSendCmd(.apiStopChat)
switch r {
case .chatStopped: return
- default: throw r
+ default: throw r.unexpected
}
}
func apiActivateChat() {
chatReopenStore()
- let r = chatSendCmdSync(.apiActivateChat(restoreChat: true))
- if case .cmdOk = r { return }
- logger.error("apiActivateChat error: \(String(describing: r))")
+ do {
+ try sendCommandOkRespSync(.apiActivateChat(restoreChat: true))
+ } catch {
+ logger.error("apiActivateChat error: \(responseError(error))")
+ }
}
func apiSuspendChat(timeoutMicroseconds: Int) {
- let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
- if case .cmdOk = r { return }
- logger.error("apiSuspendChat error: \(String(describing: r))")
+ do {
+ try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
+ } catch {
+ logger.error("apiSuspendChat error: \(responseError(error))")
+ }
}
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
- let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
- if case .cmdOk = r { return }
- throw r
+ try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable))
}
func apiSaveAppSettings(settings: AppSettings) throws {
- let r = chatSendCmdSync(.apiSaveSettings(settings: settings))
- if case .cmdOk = r { return }
- throw r
+ try sendCommandOkRespSync(.apiSaveSettings(settings: settings))
}
func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
- let r = chatSendCmdSync(.apiGetSettings(settings: settings))
+ let r: ChatResponse2 = try chatSendCmdSync(.apiGetSettings(settings: settings))
if case let .appSettings(settings) = r { return settings }
- throw r
+ throw r.unexpected
}
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
- let r = await chatSendCmd(.apiExportArchive(config: config))
+ let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config))
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
- throw r
+ throw r.unexpected
}
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
- let r = await chatSendCmd(.apiImportArchive(config: config))
+ let r: ChatResponse2 = try await chatSendCmd(.apiImportArchive(config: config))
if case let .archiveImported(archiveErrors) = r { return archiveErrors }
- throw r
+ throw r.unexpected
}
func apiDeleteStorage() async throws {
@@ -293,8 +403,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] {
@@ -307,92 +417,92 @@ func apiGetChatsAsync() async throws -> [ChatData] {
return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId)))
}
-private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
+private func apiChatsResponse(_ r: ChatResponse0) throws -> [ChatData] {
if case let .apiChats(_, chats) = r { return chats }
- throw r
+ throw r.unexpected
}
func apiGetChatTags() throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
- let r = chatSendCmdSync(.apiGetChatTags(userId: userId))
+ let r: ChatResponse0 = try chatSendCmdSync(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
- throw r
+ throw r.unexpected
}
func apiGetChatTagsAsync() async throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
- let r = await chatSendCmd(.apiGetChatTags(userId: userId))
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
- throw r
+ throw r.unexpected
}
let loadItemsPerPage = 50
-func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
- let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
- if case let .apiChat(_, chat) = r { return Chat.init(chat) }
- throw r
+func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search))
+ if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
+ 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 loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async {
+ await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems)
}
-func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async {
- do {
- let cInfo = chat.chatInfo
- let m = ChatModel.shared
- let im = ItemsModel.shared
- m.chatItemStatuses = [:]
+func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
+ await MainActor.run {
if clearItems {
- await MainActor.run { im.reversedChatItems = [] }
+ im.reversedChatItems = []
+ im.chatState.clear()
}
- let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
- await MainActor.run {
- im.reversedChatItems = chat.chatItems.reversed()
- m.updateChatInfo(chat.chatInfo)
- if (replaceChat) {
- m.replaceChat(chat.chatInfo.id, chat)
- }
- }
- } catch let error {
- logger.error("loadChat error: \(responseError(error))")
}
+ await apiLoadMessages(
+ chatId,
+ im,
+ ( // pagination
+ openAroundItemId != nil
+ ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
+ : (
+ search == ""
+ ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
+ )
+ ),
+ 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))
+func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
- throw r
+ throw r.unexpected
}
-func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
- let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
+func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
+ let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
- throw r
+ 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)
+func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] {
- let r = await chatSendCmd(.apiCreateChatTag(tag: tag))
+ let r: ChatResponse0 = try await chatSendCmd(.apiCreateChatTag(tag: tag))
if case let .chatTags(_, userTags) = r {
return userTags
}
- throw r
+ throw r.unexpected
}
func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) {
- let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds))
+ 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
+ throw r.unexpected
}
func apiDeleteChatTag(tagId: Int64) async throws {
@@ -407,14 +517,14 @@ 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)
+func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, 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 r: ChatResponse
+ let r: APIResult
if toChatType == .direct {
var cItem: ChatItem? = nil
let endTask = beginBGTask({
@@ -424,8 +534,8 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
}
}
})
- r = await chatSendCmd(cmd, bgTask: false)
- if case let .newChatItems(_, aChatItems) = r {
+ 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
@@ -436,40 +546,40 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
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 .newChatItems(_, aChatItems) = r {
+ 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 apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
- let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
- if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
- createChatItemsErrorAlert(r)
+ 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
}
func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? {
- let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText))
- if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.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))"
+ message: "Error: \(responseError(r.unexpected))"
)
return nil
}
-private func sendMessageErrorAlert(_ r: ChatResponse) {
+private func sendMessageErrorAlert(_ r: ChatError) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
@@ -477,7 +587,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
)
}
-private func createChatItemsErrorAlert(_ r: ChatResponse) {
+private func createChatItemsErrorAlert(_ r: ChatError) {
logger.error("apiCreateChatItems error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
@@ -485,42 +595,57 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) {
)
}
-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, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
+ let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, 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)
+func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
- throw r
+ throw r.unexpected
}
func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
let userId = try currentUserId("apiGetReactionMemebers")
- let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
+ 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
+ throw r.unexpected
}
-func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
- let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
+func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
- throw r
+ throw r.unexpected
}
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
- let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
- throw r
+ throw r.unexpected
+}
+
+func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse1 {
+ let r: ChatResponse1 = try await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay)
+ if case .groupChatItemsDeleted = r { return r }
+ throw r.unexpected
+}
+
+func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
+ if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions }
+ throw r.unexpected
}
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
- let r = chatSendCmdSync(.apiGetNtfToken)
+ let r: APIResult = chatApiSendCmdSync(.apiGetNtfToken)
switch r {
- case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
- case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
+ case let .result(.ntfToken(token, status, ntfMode, ntfServer)): return (token, status, ntfMode, ntfServer)
+ case .error(.errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil)
@@ -528,9 +653,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) {
@@ -542,7 +667,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))")
}
@@ -550,90 +680,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 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 = await chatSendCmd(.apiGetServerOperators)
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func getServerOperatorsSync() throws -> ServerOperatorConditions {
- let r = chatSendCmdSync(.apiGetServerOperators)
+ let r: ChatResponse0 = try chatSendCmdSync(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
- let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
+ 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
+ throw r.unexpected
}
func getUserServers() async throws -> [UserOperatorServers] {
let userId = try currentUserId("getUserServers")
- let r = await chatSendCmd(.apiGetUserServers(userId: userId))
+ 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
+ throw r.unexpected
}
func setUserServers(userServers: [UserOperatorServers]) async throws {
let userId = try currentUserId("setUserServers")
- let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
+ 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
+ throw r.unexpected
}
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
let userId = try currentUserId("validateServers")
- let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
+ 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
+ throw r.unexpected
}
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
- let r = await chatSendCmd(.apiGetUsageConditions)
+ 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
+ throw r.unexpected
}
func setConditionsNotified(conditionsId: Int64) async throws {
- let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
+ let r: ChatResponse2 = try await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
if case .cmdOk = r { return }
logger.error("setConditionsNotified error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
- let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
+ 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
+ throw r.unexpected
}
func getChatItemTTL() throws -> ChatItemTTL {
@@ -646,7 +815,7 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL {
return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId)))
}
-private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
+private func chatItemTTLResponse(_ r: ChatResponse0) throws -> ChatItemTTL {
if case let .chatItemTTL(_, chatItemTTL) = r {
if let ttl = chatItemTTL {
return ChatItemTTL(ttl)
@@ -654,7 +823,7 @@ private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
throw RuntimeError("chatItemTTLResponse: invalid ttl")
}
}
- throw r
+ throw r.unexpected
}
func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
@@ -668,21 +837,21 @@ func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async thr
}
func getNetworkConfig() async throws -> NetCfg? {
- let r = await chatSendCmd(.apiGetNetworkConfig)
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetNetworkConfig)
if case let .networkConfig(cfg) = r { return cfg }
- throw r
+ throw r.unexpected
}
func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
- let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl: ctrl)
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws {
- let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
+ let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
if case .cmdOk = r { return }
- throw r
+ throw r.unexpected
}
func reconnectAllServers() async throws {
@@ -703,131 +872,133 @@ 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 apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
- let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
+ 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 = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
+ let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
- throw r
+ throw r.unexpected
}
-func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
- let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
- if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r
+func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiContactQueueInfo(contactId: contactId))
+ if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
+ if let r { throw r.unexpected } else { return nil }
}
-func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
- let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
- if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r
+func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
+ if let r { throw r.unexpected } else { return nil }
}
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) async throws -> (GroupMember, String) {
- let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
+ 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 alert = connectionErrorAlert(r)
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
+ if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) }
+ let alert: Alert? = if let r { connectionErrorAlert(r) } else { nil }
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 apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
- let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
-
- if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
- throw r
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiChangeConnectionUser(connId: connId, userId: userId))
+ if case let .result(.connectionUserChanged(_, _, toConnection, _)) = r {return toConnection}
+ if let r { throw r.unexpected } else { return nil }
}
-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 apiConnectPlan(connLink: String, inProgress: BoxedValue) 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 chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress)
+ if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) }
+ let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
+ return (nil, alert)
}
-func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
- let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
+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
@@ -836,38 +1007,49 @@ 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 chatApiSendCmdWithRetry(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
let m = ChatModel.shared
switch r {
- case let .sentConfirmation(_, connection):
+ case let .result(.sentConfirmation(_, connection)):
return ((.invitation, connection), nil)
- case let .sentInvitation(_, connection):
+ case let .result(.sentInvitation(_, connection)):
return ((.contact, connection), nil)
- case let .contactAlreadyExists(_, contact):
+ case let .result(.contactAlreadyExists(_, contact)):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
- case .chatCmdError(_, .error(.invalidConnReq)):
- let alert = mkAlert(
+ default: ()
+ }
+ let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
+ return (nil, alert)
+}
+
+private func apiConnectResponseAlert(_ r: APIResult) -> Alert {
+ switch r.unexpected {
+ 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(.SMP(_, .BLOCKED(info)))):
- let alert = Alert(
+ 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")),
@@ -877,25 +1059,22 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
}
}
)
- return (nil, alert)
- case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
- let alert = mkAlert(
+ case .errorAgent(.SMP(_, .QUOTA)):
+ mkAlert(
title: "Undelivered messages",
message: "The connection reached the limit of undelivered messages, your contact may be offline."
)
- return (nil, alert)
- case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
+ 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)))."
+ message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(internalErr))."
)
- return (nil, alert)
+ } else {
+ connectionErrorAlert(r)
}
- default: ()
+ default: connectionErrorAlert(r)
}
- let alert = connectionErrorAlert(r)
- return (nil, alert)
}
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
@@ -905,38 +1084,81 @@ func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
)
}
-private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
+private func connectionErrorAlert(_ r: APIResult) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert
} else {
return mkAlert(
title: "Connection error",
- message: "Error: \(responseError(r))"
+ message: "Error: \(responseError(r.unexpected))"
)
}
}
+func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) async throws -> ChatData {
+ let userId = try currentUserId("apiPrepareContact")
+ let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData))
+ if case let .newPreparedChat(_, chat) = r { return chat }
+ throw r.unexpected
+}
+
+func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
+ let userId = try currentUserId("apiPrepareGroup")
+ let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData))
+ if case let .newPreparedChat(_, chat) = r { return chat }
+ throw r.unexpected
+}
+
+func apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) async throws -> Contact {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedContactUser(contactId: contactId, newUserId: newUserId))
+ if case let .contactUserChanged(_, _, _, toContact) = r {return toContact}
+ throw r.unexpected
+}
+
+func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -> GroupInfo {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedGroupUser(groupId: groupId, newUserId: newUserId))
+ if case let .groupUserChanged(_, _, _, toGroup) = r {return toGroup}
+ throw r.unexpected
+}
+
+func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg))
+ if case let .result(.startedConnectionToContact(_, contact)) = r { return contact }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
+func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
+ if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
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 alert = connectionErrorAlert(r)
- return (nil, alert)
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
+ if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) }
+ if let r {
+ logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
+ return (nil, connectionErrorAlert(r))
+ } else {
+ return (nil, nil)
+ }
}
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, chatDeleteMode: chatDeleteMode), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
- throw r
+ throw r.unexpected
}
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
@@ -950,9 +1172,9 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify:
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
- let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
- throw r
+ throw r.unexpected
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
@@ -1003,9 +1225,9 @@ func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(noti
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
- let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
+ let r: ChatResponse1 = try await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
- throw r
+ throw r.unexpected
}
func clearChat(_ chat: Chat) async {
@@ -1020,147 +1242,174 @@ 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 = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
if case let .groupAliasUpdated(_, toGroup) = r { return toGroup }
- throw r
+ throw r.unexpected
}
func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? {
- let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
+ let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection }
- throw r
+ throw r.unexpected
}
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
- let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
- if case .cmdOk = r { return true }
- logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
- return false
+ do {
+ try await sendCommandOkResp(.apiSetUserUIThemes(userId: userId, themes: themes))
+ return true
+ } catch {
+ logger.error("apiSetUserUIThemes bad response: \(responseError(error))")
+ return false
+ }
}
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
- let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
- if case .cmdOk = r { return true }
- logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
- return false
+ do {
+ try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes))
+ return true
+ } catch {
+ logger.error("apiSetChatUIThemes bad response: \(responseError(error))")
+ return false
+ }
}
-func apiCreateUserAddress() async throws -> String {
+func apiCreateUserAddress() 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: APIResult? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId))
+ if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink }
+ if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r {
+ showClientNotice(server, preset, expires)
+ return nil
+ }
+ if let r { throw r.unexpected } else { return nil }
}
func apiDeleteUserAddress() async throws -> User? {
let userId = try currentUserId("apiDeleteUserAddress")
- let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId))
- if case let .userContactLinkDeleted(user) = r { return user }
- throw r
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiDeleteMyAddress(userId: userId))
+ if case let .result(.userContactLinkDeleted(user)) = r { return user }
+ if let r { throw r.unexpected } else { return nil }
}
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))
+func apiAddMyAddressShortLink() async throws -> UserContactLink? {
+ let userId = try currentUserId("apiAddMyAddressShortLink")
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddMyAddressShortLink(userId: userId))
+ if case let .result(.userContactLink(_, contactLink)) = r { return contactLink }
+ if let r { throw r.unexpected } else { return nil }
+}
+
+func apiSetUserAddressSettings(_ settings: AddressSettings) async throws -> UserContactLink? {
+ let userId = try currentUserId("apiSetUserAddressSettings")
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiSetAddressSettings(userId: userId, addressSettings: settings))
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: if let r { throw r.unexpected } else { return nil }
}
}
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
- let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
+ let r: APIResult? = await chatApiSendCmdWithRetry(.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."
)
- } else if let networkErrorAlert = networkErrorAlert(r) {
- am.showAlert(networkErrorAlert)
- } else {
- logger.error("apiAcceptContactRequest error: \(String(describing: r))")
- am.showAlertMsg(
- title: "Error accepting contact request",
- message: "Error: \(responseError(r))"
- )
+ } else if let r {
+ if let networkErrorAlert = networkErrorAlert(r) {
+ am.showAlert(networkErrorAlert)
+ } else {
+ logger.error("apiAcceptContactRequest error: \(String(describing: r))")
+ am.showAlertMsg(
+ title: "Error accepting contact request",
+ message: "Error: \(responseError(r.unexpected))"
+ )
+ }
}
return nil
}
-func apiRejectContactRequest(contactReqId: Int64) async throws {
- let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
- if case .contactRequestRejected = r { return }
- throw r
+func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? {
+ let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
+ if case let .contactRequestRejected(_, _, contact_) = r { return contact_ }
+ throw r.unexpected
}
func apiChatRead(type: ChatType, id: Int64) async throws {
- try await sendCommandOkResp(.apiChatRead(type: type, id: id))
+ try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil))
}
-func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
- try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
+func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope))
+ if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) }
+ throw r.unexpected
+}
+
+func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds))
+ if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo }
+ throw r.unexpected
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@@ -1168,31 +1417,33 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
}
func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) {
- let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl)
- if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r {
+ let r: APIResult = await chatApiSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl: ctrl)
+ if case let .result(.sndStandaloneFileCreated(_, fileTransferMeta)) = r {
return (fileTransferMeta, nil)
} else {
- logger.error("uploadStandaloneFile error: \(String(describing: r))")
- return (nil, responseError(r))
+ let err = responseError(r.unexpected)
+ logger.error("uploadStandaloneFile error: \(err)")
+ return (nil, err)
}
}
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) {
- let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl)
- if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r {
+ let r: APIResult = await chatApiSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl: ctrl)
+ if case let .result(.rcvStandaloneFileCreated(_, rcvFileTransfer)) = r {
return (rcvFileTransfer, nil)
} else {
- logger.error("downloadStandaloneFile error: \(String(describing: r))")
- return (nil, responseError(r))
+ let err = responseError(r.unexpected)
+ logger.error("downloadStandaloneFile error: \(err)")
+ return (nil, err)
}
}
func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? {
- let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl)
- if case let .standaloneFileInfo(fileMeta) = r {
+ let r: APIResult = await chatApiSendCmd(.apiStandaloneFileInfo(url: url), ctrl: ctrl)
+ if case let .result(.standaloneFileInfo(fileMeta)) = r {
return fileMeta
} else {
- logger.error("standaloneFileInfo error: \(String(describing: r))")
+ logger.error("standaloneFileInfo error: \(responseError(r.unexpected))")
return nil
}
}
@@ -1207,12 +1458,12 @@ func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = f
}
func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
- var fileIdsToApprove = [Int64]()
- var srvsToApprove = Set()
- var otherFileErrs = [ChatResponse]()
+ var fileIdsToApprove: [Int64] = []
+ var srvsToApprove: Set = []
+ var otherFileErrs: [APIResult] = []
for fileId in fileIds {
- let r = await chatSendCmd(
+ let r: APIResult = await chatApiSendCmd(
.receiveFile(
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
@@ -1221,32 +1472,22 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
)
)
switch r {
- case let .rcvFileAccepted(_, chatItem):
+ 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:
- if let chatError = chatError(r) {
- switch chatError {
- case let .fileNotApproved(fileId, unknownServers):
- fileIdsToApprove.append(fileId)
- srvsToApprove.formUnion(unknownServers)
- default:
- otherFileErrs.append(r)
- }
- }
+ otherFileErrs.append(r)
}
}
if !auto {
- let otherErrsStr = if otherFileErrs.isEmpty {
- ""
- } else if otherFileErrs.count == 1 {
- "\(otherFileErrs[0])"
- } else if otherFileErrs.count == 2 {
- "\(otherFileErrs[0])\n\(otherFileErrs[1])"
- } else {
- "\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)"
- }
-
+ 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
@@ -1282,7 +1523,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
} else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
let errorResponse = otherFileErrs.first!
switch errorResponse {
- case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
+ case let .result(.rcvFileAcceptedSndCancelled(_, rcvFileTransfer)):
logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
await MainActor.run {
showAlert(
@@ -1290,19 +1531,14 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
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:
- if let chatError = chatError(errorResponse) {
- switch chatError {
- case .fileCancelled, .fileAlreadyReceiving:
- logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
- default:
- await MainActor.run {
- showAlert(
- NSLocalizedString("Error receiving file", comment: "alert title"),
- message: responseError(errorResponse)
- )
- }
- }
+ 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
@@ -1314,6 +1550,20 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
}
}
}
+
+ 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 {
@@ -1324,12 +1574,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
}
}
@@ -1339,9 +1589,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 {
@@ -1349,21 +1599,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 {
@@ -1374,37 +1624,60 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
-func networkErrorAlert(_ r: ChatResponse) -> Alert? {
- if let alert = getNetworkErrorAlert(r) {
+func networkErrorAlert(_ res: APIResult) -> Alert? {
+ if case let .error(e) = res, let alert = getNetworkErrorAlert(e) {
return mkAlert(title: alert.title, message: alert.message)
} else {
return nil
}
}
-func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
- if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
+func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: Binding? = nil) async {
+ await MainActor.run { inProgress?.wrappedValue = true }
+ if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequestId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run {
- ChatModel.shared.replaceChat(contactRequest.id, chat)
- NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ if contact.contactRequestId != nil { // means contact request was initially created with contact, so we don't need to replace it
+ ChatModel.shared.updateContact(contact)
+ } else {
+ ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat)
+ }
+ inProgress?.wrappedValue = false
}
if contact.sndReady {
+ let chatId = chat.id
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
- ItemsModel.shared.loadOpenChat(chat.id)
+ ItemsModel.shared.loadOpenChat(chatId)
}
}
}
+ } else {
+ await MainActor.run { inProgress?.wrappedValue = false }
}
}
-func rejectContactRequest(_ contactRequest: UserContactRequest) async {
+func rejectContactRequest(_ contactRequestId: Int64, dismissToChatList: Bool = false) async {
do {
- try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
- DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
+ let contact_ = try await apiRejectContactRequest(contactReqId: contactRequestId)
+ await MainActor.run {
+ if let contact = contact_ { // means contact request was initially created with contact, so we need to remove contact chat
+ ChatModel.shared.removeChat(contact.id)
+ } else {
+ ChatModel.shared.removeChat(contactRequestChatId(contactRequestId))
+ }
+ if dismissToChatList {
+ ChatModel.shared.chatId = nil
+ }
+ }
} catch let error {
logger.error("rejectContactRequest: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error rejecting contact request", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
}
}
@@ -1437,15 +1710,15 @@ func apiEndCall(_ contact: Contact) async throws {
}
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
- let r = chatSendCmdSync(.apiGetCallInvitations)
+ let r: ChatResponse2 = try chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
- throw r
+ throw r.unexpected
}
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
- let r = await chatSendCmd(.apiGetCallInvitations)
+ let r: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
- throw r
+ throw r.unexpected
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
@@ -1456,19 +1729,13 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
}
}
-func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
- let r = chatSendCmdSync(.apiGetNetworkStatuses)
- if case let .networkStatuses(_, statuses) = r { return statuses }
- throw r
-}
-
-func markChatRead(_ chat: Chat) async {
+func markChatRead(_ im: ItemsModel, _ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
- withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
+ withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) }
}
}
if chat.chatStats.unreadChat {
@@ -1491,40 +1758,55 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
-func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
+func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async {
do {
- try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
- DispatchQueue.main.async {
- ChatModel.shared.markChatItemsRead(cInfo, itemIds)
+ if member.supportChatNotRead {
+ let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId))
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
+ ChatModel.shared.updateGroup(updatedGroupInfo)
+ }
+ }
+ } catch {
+ logger.error("markSupportChatRead apiChatRead error: \(responseError(error))")
+ }
+}
+
+func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
+ do {
+ let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
+ await MainActor.run {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead)
}
} catch {
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 {
@@ -1533,32 +1815,44 @@ enum JoinGroupResult {
case groupNotFound
}
-func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
- let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
+func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.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: if let r { throw r.unexpected } else { return nil }
}
}
-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 apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole))
+ if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) }
+ 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 apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) }
+ 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 apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
+ if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) }
+ throw r.unexpected
+}
+
+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 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 {
@@ -1571,92 +1865,129 @@ 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 []
}
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.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) }
+ .compactMap{ c in c.chatInfo.sendMsgEnabled ? c.chatInfo.contact : nil }
+ .filter{ c in !c.sendMsgToConnect && !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 -> GroupLink? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
+ if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink }
+ if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r {
+ showClientNotice(server, preset, expires)
+ return nil
+ }
+ if let r { throw r.unexpected } else { return nil }
}
-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 -> GroupLink {
+ let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
+ if case let .groupLink(_, _, groupLink) = r { return groupLink }
+ throw r.unexpected
}
func apiDeleteGroupLink(_ groupId: Int64) async throws {
- let r = await chatSendCmd(.apiDeleteGroupLink(groupId: groupId))
- if case .groupLinkDeleted = r { return }
- throw r
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiDeleteGroupLink(groupId: groupId))
+ if case .result(.groupLinkDeleted) = r { return }
+ if let r { throw r.unexpected }
}
-func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
- let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId))
+func apiGetGroupLink(_ groupId: Int64) throws -> GroupLink? {
+ 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(_, _, groupLink)):
+ return groupLink
+ case .error(.errorStore(storeError: .groupLinkNotFound)):
return nil
- default: throw r
+ default: throw r.unexpected
}
}
+func apiAddGroupShortLink(_ groupId: Int64) async throws -> GroupLink? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddGroupShortLink(groupId: groupId))
+ if case let .result(.groupLink(_, _, groupLink)) = r { return groupLink }
+ if let r { throw r.unexpected } else { return nil }
+}
+
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 apiAcceptMemberContact(contactId: Int64) async -> Contact? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAcceptMemberContact(contactId: contactId))
+ if case let .result(.memberContactAccepted(_, contact)) = r { return contact }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
+func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) async {
+ await MainActor.run { inProgress?.wrappedValue = true }
+ if let contact = await apiAcceptMemberContact(contactId: contactId) {
+ await MainActor.run {
+ ChatModel.shared.updateContact(contact)
+ inProgress?.wrappedValue = false
+ }
+ if contact.sndReady {
+ DispatchQueue.main.async {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(contact.id)
+ }
+ }
+ }
+ } else {
+ await MainActor.run { inProgress?.wrappedValue = false }
+ }
}
func apiGetVersion() throws -> CoreVersionInfo {
- let r = chatSendCmdSync(.showVersion)
+ let r: ChatResponse2 = try chatSendCmdSync(.showVersion)
if case let .versionInfo(info, _, _) = r { return info }
- throw r
+ throw r.unexpected
}
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
- let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
+ let r: ChatResponse2 = try await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
- let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
+ let r: ChatResponse2 = try chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
- throw r
+ throw r.unexpected
}
func resetAgentServersStats() async throws {
@@ -1800,7 +2131,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
try getUserChatData()
}
-func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws {
+func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
let currentUser = if let userId = userId {
try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
} else {
@@ -1812,7 +2143,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws {
m.currentUser = currentUser
m.users = users
}
- try await getUserChatDataAsync()
+ try await getUserChatDataAsync(keepingChatId: keepingChatId)
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
@@ -1834,7 +2165,7 @@ func getUserChatData() throws {
tm.updateChatTags(m.chats)
}
-private func getUserChatDataAsync() async throws {
+private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if m.currentUser != nil {
@@ -1845,7 +2176,7 @@ private func getUserChatDataAsync() async throws {
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
- m.updateChats(chats)
+ m.updateChats(chats, keepingChatId: keepingChatId)
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
@@ -1866,7 +2197,7 @@ class ChatReceiver {
private var receiveMessages = true
private var _lastMsgTime = Date.now
- var messagesChannel: ((ChatResponse) -> Void)? = nil
+ var messagesChannel: ((APIResult) -> Void)? = nil
static let shared = ChatReceiver()
@@ -1884,7 +2215,12 @@ class ChatReceiver {
while self.receiveMessages {
if let msg = await chatRecvMsg() {
self._lastMsgTime = .now
- await processReceivedMsg(msg)
+ Task { await TerminalItems.shared.addResult(msg) }
+ switch msg {
+ case let .result(evt): await processReceivedMsg(evt)
+ case let .error(err): logger.debug("chatRecvMsg error: \(responseError(err))")
+ case let .invalid(type, json): logger.debug("chatRecvMsg event: * \(type) \(dataToString(json))")
+ }
if let messagesChannel {
messagesChannel(msg)
}
@@ -1901,12 +2237,8 @@ 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):
@@ -1923,14 +2255,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
+ if contact.id == m.chatId, let conn = contact.activeConn {
+ m.chatAgentConnId = conn.agentConnId
+ m.chatSubStatus = .active
+ }
}
}
if contact.directOrUsed {
NtfManager.shared.notifyContactConnected(user, contact)
}
- await MainActor.run {
- n.setContactNetworkStatus(contact, .connected)
- }
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
@@ -1951,20 +2284,27 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
- await MainActor.run {
- n.setContactNetworkStatus(contact, .connected)
- }
- case let .receivedContactRequest(user, contactRequest):
+ case let .receivedContactRequest(user, contactRequest, chat_):
if active(user) {
- let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
await MainActor.run {
- if m.hasChat(contactRequest.id) {
- m.updateChatInfo(cInfo)
+ if let chat = chat_ { // means contact request was created with contact, so we need to add/update contact chat
+ if !m.hasChat(chat.id) {
+ m.addChat(Chat(chat))
+ } else if m.chatId == chat.id {
+ m.updateChatInfo(chat.chatInfo)
+ } else {
+ m.replaceChat(chat.id, Chat(chat))
+ }
} else {
- m.addChat(Chat(
- chatInfo: cInfo,
- chatItems: []
- ))
+ let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
+ if m.hasChat(contactRequest.id) {
+ m.updateChatInfo(cInfo)
+ } else {
+ m.addChat(Chat(
+ chatInfo: cInfo,
+ chatItems: []
+ ))
+ }
}
}
}
@@ -1982,39 +2322,16 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertGroupMember(groupInfo, toMember)
}
}
- case let .contactsMerged(user, intoContact, mergedContact):
- if active(user) && m.hasChat(mergedContact.id) {
+ case let .subscriptionStatus(status, connections):
+ if let chatAgentConnId = m.chatAgentConnId, connections.contains(chatAgentConnId) {
await MainActor.run {
- if m.chatId == mergedContact.id {
- ItemsModel.shared.loadOpenChat(mergedContact.id)
- }
- m.removeChat(mergedContact.id)
+ m.chatSubStatus = status
}
}
- case let .networkStatus(status, connections):
- // dispatch queue to synchronize access
- networkStatusesLock.sync {
- 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 {
- n.networkStatuses = ns
- }
- }
- case let .networkStatuses(_, statuses): ()
- // dispatch queue to synchronize access
- networkStatusesLock.sync {
- 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 {
- n.networkStatuses = ns
+ case let .chatInfoUpdated(user, chatInfo):
+ if active(user) {
+ await MainActor.run {
+ m.updateChatInfo(chatInfo)
}
}
case let .newChatItems(user, chatItems):
@@ -2027,7 +2344,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
- } else if cItem.isRcvNew && cInfo.ntfsEnabled {
+ } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
m.increaseUnreadCounter(user: user)
}
}
@@ -2045,7 +2362,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) {
- await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
+ _ = await MainActor.run { m.upsertChatItem(cInfo, cItem) }
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
@@ -2072,7 +2389,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .chatItemsDeleted(user, items, _):
if !active(user) {
for item in items {
- if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
+ let d = item.deletedChatItem
+ if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
@@ -2088,42 +2406,16 @@ func processReceivedMsg(_ res: ChatResponse) async {
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
+ if item.deletedChatItem.chatItem.isActiveReport {
+ m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id)
+ }
+ }
+ if let updatedChatInfo = items.last?.deletedChatItem.chatInfo {
+ m.updateChatInfo(updatedChatInfo)
}
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
- 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 }
- }
- }
+ await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {
@@ -2143,7 +2435,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 {
@@ -2169,21 +2461,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertGroupMember(groupInfo, member)
}
}
- case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
+ case let .memberAcceptedByOther(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
+ _ = m.upsertGroupMember(groupInfo, member)
m.updateGroup(groupInfo)
}
}
- case let .deletedMember(user, groupInfo, _, deletedMember):
+ 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, byMember, deletedMember, withMessages):
+ if active(user) {
+ await MainActor.run {
+ m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, deletedMember)
+ if withMessages {
+ m.removeMemberItems(deletedMember, byMember: byMember, groupInfo)
+ }
}
}
case let .leftMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
+ m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
@@ -2198,6 +2505,17 @@ func processReceivedMsg(_ res: ChatResponse) async {
await MainActor.run {
m.updateGroup(groupInfo)
}
+ if m.chatId == groupInfo.id {
+ if groupInfo.membership.memberPending {
+ await MainActor.run {
+ m.secondaryPendingInviteeChatOpened = true
+ }
+ } else if case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
+ await MainActor.run {
+ m.secondaryPendingInviteeChatOpened = false
+ }
+ }
+ }
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
@@ -2211,11 +2529,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertGroupMember(groupInfo, member)
}
}
- if let contact = memberContact {
- await MainActor.run {
- n.setContactNetworkStatus(contact, .connected)
- }
- }
case let .groupUpdated(user, toGroup):
if active(user) {
await MainActor.run {
@@ -2244,6 +2557,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 legacy 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):
@@ -2437,13 +2754,10 @@ 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) }
- n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error {
logger.debug("error updating chat data: \(responseError(error))")
}
@@ -2466,6 +2780,43 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
}
}
+func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async {
+ let m = ChatModel.shared
+ if !active(user) {
+ do {
+ let users = try listUsers()
+ await MainActor.run {
+ m.users = users
+ }
+ } catch {
+ logger.error("Error loading users: \(error)")
+ }
+ return
+ }
+ let im = ItemsModel.shared
+ let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
+ await MainActor.run {
+ m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
+ }
+ var notFound = chatItemIDs.count
+ for ci in im.reversedChatItems {
+ if chatItemIDs.contains(ci.id) {
+ let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId {
+ CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_)
+ } else {
+ CIDeleted.deleted(deletedTs: Date.now)
+ }
+ await MainActor.run {
+ var newItem = ci
+ newItem.meta.itemDeleted = deleted
+ _ = m.upsertChatItem(cInfo, newItem)
+ }
+ notFound -= 1
+ if notFound == 0 { break }
+ }
+ }
+}
+
func refreshCallInvitations() async throws {
let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations()
@@ -2514,3 +2865,26 @@ private struct UserResponse: Decodable {
var user: User?
var error: String?
}
+
+private func showClientNotice(_ server: String, _ preset: Bool, _ expiresAt: Date?) {
+ DispatchQueue.main.async {
+ var message = "Server: \(server).\nConditions of use violation notice received from \(preset ? "preset" : "this") server.\nNo IDs shared, see How it works."
+ if let expiresAt {
+ message += "\n\nNew addresses can be created after \(expiresAt.formatted(date: .abbreviated, time: .shortened))."
+ }
+ showAlert("Not allowed", message: message) {
+ let howItWorks = UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
+ UIApplication.shared.open(contentModerationPostLink)
+ })
+ return preset
+ ? [
+ okAlertAction,
+ UIAlertAction(title: NSLocalizedString("Conditions of use", comment: "alert button"), style: .default, handler: { _ in
+ UIApplication.shared.open(conditionsURL)
+ }),
+ howItWorks
+ ]
+ : [okAlertAction, howItWorks]
+ }
+ }
+}
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 10120db185..e1a6bb61e8 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -42,7 +42,11 @@ struct SimpleXApp: App {
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
- chatModel.appOpenUrl = url
+ if AppChatState.shared.value == .active {
+ chatModel.appOpenUrl = url
+ } else {
+ chatModel.appOpenUrlLater = url
+ }
}
.onAppear() {
// Present screen for continue migration if it wasn't finished yet
@@ -93,7 +97,16 @@ struct SimpleXApp: App {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
+ if let url = chatModel.appOpenUrlLater {
+ await MainActor.run {
+ chatModel.appOpenUrlLater = nil
+ chatModel.appOpenUrl = url
+ }
+ }
}
+ } else if let url = chatModel.appOpenUrlLater {
+ chatModel.appOpenUrlLater = nil
+ chatModel.appOpenUrl = url
}
}
}
@@ -145,12 +158,12 @@ struct SimpleXApp: App {
if let id = chatModel.chatId,
let chat = chatModel.getChat(id),
!NtfManager.shared.navigatingToChat {
- Task { await loadChat(chat: chat, clearItems: false) }
+ Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
- Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
}
}
} catch let error {
diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift
index de67390026..3bd8f00c25 100644
--- a/apps/ios/Shared/Theme/Theme.swift
+++ b/apps/ios/Shared/Theme/Theme.swift
@@ -42,12 +42,14 @@ class AppTheme: ObservableObject, Equatable {
}
func updateFromCurrentColors() {
- objectWillChange.send()
- name = CurrentColors.name
- base = CurrentColors.base
- colors.updateColorsFrom(CurrentColors.colors)
- appColors.updateColorsFrom(CurrentColors.appColors)
- wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
+ DispatchQueue.main.async {
+ self.objectWillChange.send()
+ self.name = CurrentColors.name
+ self.base = CurrentColors.base
+ self.colors.updateColorsFrom(CurrentColors.colors)
+ self.appColors.updateColorsFrom(CurrentColors.appColors)
+ self.wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
+ }
}
}
diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift
index 2f76f1f046..ab7a47b944 100644
--- a/apps/ios/Shared/Views/Call/ActiveCallView.swift
+++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift
@@ -243,7 +243,7 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil
return
}
- if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
+ if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg,
chatItems.contains(where: { ci in
ci.chatInfo.id == call.contact.id &&
ci.chatItem.content.isSndCall &&
@@ -361,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: ")")
}
}
}
@@ -390,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: ")")
}
}
}
@@ -467,7 +467,7 @@ struct ActiveCallOverlay: View {
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
}
- @ViewBuilder private func flipCameraButton() -> some View {
+ private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
Task {
if await WebRTCClient.isAuthorized(for: .video) {
@@ -477,11 +477,11 @@ struct ActiveCallOverlay: View {
}
}
- @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
+ 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)
}
- @ViewBuilder private func audioDevicePickerButton() -> some View {
+ private func audioDevicePickerButton() -> some View {
AudioDevicePicker()
.opacity(0.8)
.scaleEffect(2)
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index 62a41c504a..37f3b982a1 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -12,6 +12,7 @@ import SimpleXChat
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
var imageSize: CGFloat = 32
@@ -22,11 +23,28 @@ struct ChatInfoToolbar: View {
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
Spacer().frame(width: 16)
}
- ChatInfoImage(
- chat: chat,
- size: imageSize,
- color: Color(uiColor: .tertiaryLabel)
- )
+ ZStack(alignment: .bottomTrailing) {
+ ChatInfoImage(
+ chat: chat,
+ size: imageSize,
+ color: Color(uiColor: .tertiaryLabel)
+ )
+ if chat.chatStats.reportsCount > 0 {
+ Image(systemName: "flag.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 14, height: 14)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ } else if chat.supportUnreadCount > 0 {
+ Image(systemName: "flag.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 14, height: 14)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, theme.colors.primary)
+ }
+ }
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
@@ -39,6 +57,13 @@ struct ChatInfoToolbar: View {
.padding(.top, -2)
}
}
+ if let contact = chat.chatInfo.contact,
+ contact.ready && contact.active,
+ let chatSubStatus = m.chatSubStatus,
+ chatSubStatus != .active {
+ SubStatusView(status: chatSubStatus)
+ .padding(.leading, 4)
+ }
}
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
@@ -51,6 +76,30 @@ struct ChatInfoToolbar: View {
.baselineOffset(1)
.kerning(-2)
}
+
+ struct SubStatusView: View {
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ @EnvironmentObject var theme: AppTheme
+ var status: SubscriptionStatus
+
+ var body: some View {
+ switch status {
+ case .active: EmptyView()
+ case .pending: ProgressView()
+ case .removed: subStatusError()
+ case .noSub: subStatusError()
+ }
+ }
+
+ @ViewBuilder private func subStatusError() -> some View {
+ let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
+ Image(systemName: "exclamationmark.circle")
+ .resizable()
+ .scaledToFit()
+ .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
}
struct ChatInfoToolbar_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 7a5003c94d..ad82af05e2 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 {
@@ -92,7 +92,6 @@ 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
@State var localAlias: String
@@ -111,10 +110,11 @@ struct ChatInfoView: View {
@State private var sendReceiptsUserDefault = true
@State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
-
+ @State private var showSecrets: Set = []
+
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
- case networkStatusAlert
+ case subStatusAlert(status: SubscriptionStatus)
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
@@ -125,7 +125,7 @@ struct ChatInfoView: View {
var id: String {
switch self {
case .clearChatAlert: return "clearChatAlert"
- case .networkStatusAlert: return "networkStatusAlert"
+ case let .subStatusAlert(status): return "subStatusAlert \(status)"
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
@@ -135,7 +135,7 @@ struct ChatInfoView: View {
}
}
}
-
+
var body: some View {
NavigationView {
ZStack {
@@ -146,19 +146,21 @@ struct ChatInfoView: View {
.onTapGesture {
aliasTextFieldFocused = false
}
-
+
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
-
+
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
- muteButton(width: buttonWidth)
+ if let nextNtfMode = chat.chatInfo.nextNtfMode {
+ muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
+ }
}
}
.padding(.trailing)
@@ -167,7 +169,7 @@ struct ChatInfoView: View {
.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 {
@@ -178,7 +180,7 @@ struct ChatInfoView: View {
}
}
}
-
+
Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
@@ -201,19 +203,19 @@ struct ChatInfoView: View {
// }
}
.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)
@@ -230,13 +232,15 @@ struct ChatInfoView: View {
.foregroundColor(theme.colors.secondary)
}
}
-
+
if contact.ready && contact.active {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
- networkStatusRow()
- .onTapGesture {
- alert = .networkStatusAlert
- }
+ if let chatSubStatus = chatModel.chatSubStatus {
+ SubStatusRow(status: chatSubStatus)
+ .onTapGesture {
+ alert = .subStatusAlert(status: chatSubStatus)
+ }
+ }
if let connStats = connectionStats {
Button("Change receiving address") {
alert = .switchAddressAlert
@@ -259,12 +263,12 @@ struct ChatInfoView: View {
}
}
}
-
+
Section {
clearChatButton()
deleteContactButton()
}
-
+
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -272,8 +276,9 @@ struct ChatInfoView: View {
Button ("Debug delivery") {
Task {
do {
- let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
- await MainActor.run { alert = .queueInfo(info: info) }
+ if let info = try await apiContactQueueInfo(chat.chatInfo.apiId) {
+ await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
+ }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
@@ -288,7 +293,7 @@ struct ChatInfoView: View {
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
-
+
if progressIndicator {
ProgressView().scaleEffect(2)
}
@@ -300,7 +305,7 @@ 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)
@@ -321,7 +326,7 @@ struct ChatInfoView: View {
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .clearChatAlert: return clearChatAlert()
- case .networkStatusAlert: return networkStatusAlert()
+ case let .subStatusAlert(status): return subStatusAlert(status)
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert:
@@ -339,7 +344,7 @@ struct ChatInfoView: View {
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
- .sheet(item: $sheet) {
+ .sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction($0.fraction)])
@@ -358,41 +363,52 @@ struct ChatInfoView: View {
}
}
}
-
+
private func contactInfoHeader() -> some View {
VStack(spacing: 8) {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.vertical, 12)
+ // show actual display name, alias can be edited in this view
+ let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
- + Text(contact.profile.displayName)
+ + Text(displayName)
.font(.largeTitle)
)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
- Text(contact.profile.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
- if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
+ if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
.multilineTextAlignment(.center)
.lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
-
+
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
@@ -409,7 +425,7 @@ struct ChatInfoView: View {
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
-
+
private func setContactAlias() {
Task {
do {
@@ -432,13 +448,13 @@ struct ChatInfoView: View {
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
- private func muteButton(width: CGFloat) -> some View {
- InfoViewButton(
- image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
- title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: false))",
width: width
) {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!contact.ready || !contact.active)
}
@@ -472,7 +488,7 @@ struct ChatInfoView: View {
)
}
}
-
+
private func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
@@ -488,21 +504,20 @@ struct ChatInfoView: View {
Label("Contact preferences", systemImage: "switch.2")
}
}
-
+
private func sendReceiptsOption() -> some View {
- Picker(selection: $sendReceipts) {
+ WrappedPicker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
Text(opt.text)
}
} label: {
Label("Send receipts", systemImage: "checkmark.message")
}
- .frame(height: 36)
.onChange(of: sendReceipts) { _ in
setSendReceipts()
}
}
-
+
private func setSendReceipts() {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.sendRcpts = sendReceipts.bool()
@@ -522,7 +537,7 @@ struct ChatInfoView: View {
.foregroundColor(.orange)
}
}
-
+
private func synchronizeConnectionButtonForce() -> some View {
Button {
alert = .syncConnectionForceAlert
@@ -531,27 +546,7 @@ struct ChatInfoView: View {
.foregroundColor(.red)
}
}
-
- private func networkStatusRow() -> some View {
- HStack {
- Text("Network status")
- Image(systemName: "info.circle")
- .foregroundColor(theme.colors.primary)
- .font(.system(size: 14))
- Spacer()
- Text(networkModel.contactNetworkStatus(contact).statusString)
- .foregroundColor(theme.colors.secondary)
- serverImage()
- }
- }
-
- private func serverImage() -> some View {
- let status = networkModel.contactNetworkStatus(contact)
- return Image(systemName: status.imageName)
- .foregroundColor(status == .connected ? .green : theme.colors.secondary)
- .font(.system(size: 12))
- }
-
+
private func deleteContactButton() -> some View {
Button(role: .destructive) {
deleteContactDialog(
@@ -567,7 +562,7 @@ struct ChatInfoView: View {
.foregroundColor(Color.red)
}
}
-
+
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
@@ -576,7 +571,7 @@ struct ChatInfoView: View {
.foregroundColor(Color.orange)
}
}
-
+
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -590,14 +585,14 @@ struct ChatInfoView: View {
secondaryButton: .cancel()
)
}
-
- private func networkStatusAlert() -> Alert {
+
+ private func subStatusAlert(_ status: SubscriptionStatus) -> Alert {
Alert(
title: Text("Network status"),
- message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
+ message: Text(status.statusExplanation)
)
}
-
+
private func switchContactAddress() {
Task {
do {
@@ -616,7 +611,7 @@ struct ChatInfoView: View {
}
}
}
-
+
private func abortSwitchContactAddress() {
Task {
do {
@@ -634,7 +629,7 @@ struct ChatInfoView: View {
}
}
}
-
+
private func savePreferences() {
Task {
do {
@@ -653,6 +648,30 @@ struct ChatInfoView: View {
}
}
+struct SubStatusRow: View {
+ @EnvironmentObject var theme: AppTheme
+ var status: SubscriptionStatus
+
+ var body: some View {
+ HStack {
+ Text("Network status")
+ Image(systemName: "info.circle")
+ .foregroundColor(theme.colors.primary)
+ .font(.system(size: 14))
+ Spacer()
+ Text(status.statusString)
+ .foregroundColor(theme.colors.secondary)
+ serverImage(status)
+ }
+ }
+
+ private func serverImage(_ status: SubscriptionStatus) -> some View {
+ return Image(systemName: status.imageName)
+ .foregroundColor(status == .active ? .green : theme.colors.secondary)
+ .font(.system(size: 12))
+ }
+}
+
struct ChatTTLOption: View {
@ObservedObject var chat: Chat
@Binding var progressIndicator: Bool
@@ -660,19 +679,18 @@ struct ChatTTLOption: View {
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
var body: some View {
- Picker("Delete messages after", selection: $chatItemTTL) {
+ WrappedPicker("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(
@@ -682,17 +700,23 @@ struct ChatTTLOption: View {
) {
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, replaceChat: true)
+ await loadChat(chat: chat, im: ItemsModel.shared, 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, replaceChat: true)
+ await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true)
await MainActor.run {
chatItemTTL = currentChatItemTTL
progressIndicator = false
@@ -825,7 +849,7 @@ private struct CallButton: View {
))
}
}
- } else if contact.nextSendGrpInv {
+ } else if contact.sendMsgToConnect {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
@@ -930,7 +954,7 @@ struct ChatWallpaperEditorSheet: View {
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 {
+ } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -966,7 +990,7 @@ struct ChatWallpaperEditorSheet: View {
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 {
+ } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -1044,12 +1068,12 @@ struct ChatWallpaperEditorSheet: View {
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
themes = themesFromChat(chat.wrappedValue)
}
- } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
+ } 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))
+ ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
+ chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
themes = themesFromChat(chat.wrappedValue)
}
}
@@ -1129,13 +1153,13 @@ func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () ->
} 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(
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
index 3b3e1b3899..0283e9c07e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift
@@ -50,7 +50,7 @@ struct CICallItemView: View {
Image(systemName: "phone.connection").foregroundColor(.green)
}
- @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
+ private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
@@ -60,16 +60,16 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo {
- Button {
- if let invitation = m.callInvitations[contact.id] {
- CallController.shared.answerCall(invitation: invitation)
- logger.debug("acceptCallButton call answered")
- } else {
- AlertManager.shared.showAlertMsg(title: "Call already ended!")
- }
- } label: {
- Label("Answer call", systemImage: "phone.arrow.down.left")
- }
+ Label("Answer call", systemImage: "phone.arrow.down.left")
+ .foregroundColor(theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded {
+ if let invitation = m.callInvitations[contact.id] {
+ CallController.shared.answerCall(invitation: invitation)
+ logger.debug("acceptCallButton call answered")
+ } else {
+ AlertManager.shared.showAlertMsg(title: "Call already ended!")
+ }
+ })
} else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
index 02be8af73b..b2b4441646 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
@@ -12,8 +12,8 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
- @ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: Feature
@@ -53,7 +53,7 @@ struct CIChatFeatureView: View {
private func mergedFeatures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set = []
- if var i = m.getChatItemIndex(chatItem) {
+ if var i = m.getChatItemIndex(im, chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
@@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(
chat: Chat.sampleData,
+ im: ItemsModel.shared,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
index 2c9c261536..67f7b69e2c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
@@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View {
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
- }
+ })
} else {
featurePreferenceView()
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index a785f3e6d8..1b9376b5db 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -19,42 +19,42 @@ struct CIFileView: View {
var body: some View {
if smallViewSize != nil {
fileIndicator()
- .onTapGesture(perform: fileAction)
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
} else {
let metaReserve = edited
? " "
: " "
- Button(action: fileAction) {
- HStack(alignment: .bottom, spacing: 6) {
- fileIndicator()
- .padding(.top, 5)
- .padding(.bottom, 3)
- if let file = file {
- let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
- VStack(alignment: .leading, spacing: 2) {
- Text(file.fileName)
- .lineLimit(1)
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.onBackground)
- Text(prettyFileSize + metaReserve)
- .font(.caption)
- .lineLimit(1)
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.secondary)
- }
- } else {
- Text(metaReserve)
+ HStack(alignment: .bottom, spacing: 6) {
+ fileIndicator()
+ .padding(.top, 5)
+ .padding(.bottom, 3)
+ if let file = file {
+ let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(file.fileName)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(theme.colors.onBackground)
+ Text(prettyFileSize + metaReserve)
+ .font(.caption)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(theme.colors.secondary)
}
+ } else {
+ Text(metaReserve)
}
- .padding(.top, 4)
- .padding(.bottom, 6)
- .padding(.leading, 10)
- .padding(.trailing, 12)
}
+ .padding(.top, 4)
+ .padding(.bottom, 6)
+ .padding(.leading, 10)
+ .padding(.trailing, 12)
+ .simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive)
}
}
+ @inline(__always)
private var itemInteractive: Bool {
if let file = file {
switch (file.fileStatus) {
@@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
- 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))
- ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, 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), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
}
.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 107208a033..3fcf578875 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
@@ -84,12 +84,12 @@ struct CIGroupInvitationView: View {
}
if action {
- v.onTapGesture {
+ v.simultaneousGesture(TapGesture().onEnded {
inProgress = true
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
- }
+ })
.disabled(inProgress)
} else {
v
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index d491563913..d1f49f635a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
+ var scrollToItem: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@@ -25,12 +26,14 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
- FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
+ FullScreenMediaView(chatItem: chatItem, scrollToItem: scrollToItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
- .onTapGesture { showFullScreenImage = true }
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
+ }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
@@ -42,7 +45,7 @@ struct CIImageView: View {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
@@ -79,7 +82,7 @@ struct CIImageView: View {
default: ()
}
}
- }
+ })
}
}
.onDisappear {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
index 18fd682646..5e9fa691de 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift
@@ -7,10 +7,11 @@
//
import SwiftUI
+import SimpleXChat
struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
- var json: String
+ var json: Data?
@State private var showJSON = false
var body: some View {
@@ -23,16 +24,16 @@ struct CIInvalidJSONView: View {
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
- .onTapGesture { showJSON = true }
+ .simultaneousGesture(TapGesture().onEnded { showJSON = true })
.appSheet(isPresented: $showJSON) {
- invalidJSONView(json)
+ invalidJSONView(dataToString(json))
}
}
}
func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
- Button {
+ Button { // this is used in the sheet, Button works here
showShareSheet(items: [json])
} label: {
Image(systemName: "square.and.arrow.up")
@@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View {
struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View {
- CIInvalidJSONView(json: "{}")
+ CIInvalidJSONView(json: "{}".data(using: .utf8)!)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index 692e6bb8a6..f07e90b953 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -21,30 +21,94 @@ struct CILinkView: View {
.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)
+ Text(linkPreview.uri)
.font(.caption)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
+ .simultaneousGesture(TapGesture().onEnded {
+ openBrowserAlert(uri: linkPreview.uri)
+ })
}
}
}
+func openBrowserAlert(uri: String) {
+ let (url, err) = sanitizeUri(uri)
+ if let url {
+ let uriStr = url.uri.absoluteString
+ showAlert(
+ NSLocalizedString("Open link?", comment: "alert title"),
+ message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr,
+ actions: {
+ if let sanitizedUri = url.sanitizedUri {
+ [
+ cancelAlertAction,
+ UIAlertAction(
+ title: NSLocalizedString("Open full link", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(url.uri) }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Open clean link", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(sanitizedUri) }
+ )
+ ]
+ } else {
+ [
+ cancelAlertAction,
+ UIAlertAction(
+ title: NSLocalizedString("Open", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(url.uri) }
+ )
+ ]
+ }
+ }
+ )
+ } else {
+ showInvalidLinkAlert(uri, error: err)
+ }
+}
+
+func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
+ let message = if let error, !error.isEmpty {
+ error + "\n" + uri
+ } else {
+ uri
+ }
+ showAlert(
+ NSLocalizedString("Invalid link", comment: "alert title"),
+ message: message,
+ actions: {[okAlertAction]}
+ )
+}
+
+func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) {
+ let parsed = parseSanitizeUri(s, safe: false)
+ return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo {
+ (url: (uri: uri, sanitizedUri: uriInfo.sanitized.flatMap { URL(string: $0) }), error: nil)
+ } else {
+ (url: nil, error: parsed?.parseError)
+ }
+}
+
struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(
- uri: URL(string: "http://DuckDuckGo.com")!,
+ uri: "http://DuckDuckGo.com",
title: "Privacy, simplified.",
description: "",
image: "data:image/jpg;base64,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"
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
index d24c737907..2898a318a9 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
@@ -20,12 +20,11 @@ struct CIMemberCreatedContactView: View {
case let .groupRcv(groupMember):
if let contactId = groupMember.memberContactId {
memberCreatedContactView(openText: "Open")
- .onTapGesture {
- dismissAllSheets(animated: true)
- DispatchQueue.main.async {
- ItemsModel.shared.loadOpenChat("@\(contactId)")
+ .simultaneousGesture(TapGesture().onEnded {
+ ItemsModel.shared.loadOpenChat("@\(contactId)") {
+ dismissAllSheets(animated: true)
}
- }
+ })
} else {
memberCreatedContactView()
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index e58ad0f74e..fc73778239 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -15,7 +15,7 @@ struct CIMetaView: View {
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var metaColor: Color
- var paleMetaColor = Color(UIColor.tertiaryLabel)
+ var paleMetaColor = Color(uiColor: .tertiaryLabel)
var showStatus = true
var showEdited = true
var invertedMaterial = false
@@ -152,11 +152,13 @@ func ciMetaText(
return r.font(.caption)
}
+@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)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index 4603a026cd..3201332c1e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View {
viewBody()
.onAppear {
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
- if case let .group(groupInfo) = chat.chatInfo,
+ if case let .group(groupInfo, _) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
@@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View {
}
}
- @ViewBuilder private func viewBody() -> some View {
+ private func viewBody() -> some View {
Group {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
@@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View {
} else {
basicDecryptionErrorItem()
}
- } else if case let .group(groupInfo) = chat.chatInfo,
+ } 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 {
@@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
- .onTapGesture(perform: { onClick() })
+ .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6)
.textSelection(.disabled)
}
@@ -151,7 +151,7 @@ struct CIRcvDecryptionError: View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
- .onTapGesture(perform: { onClick() })
+ .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6)
.textSelection(.disabled)
}
@@ -161,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 f774299ad3..eacbe9360a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -47,57 +47,57 @@ struct CIVideoView: View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
- if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
- smallVideoView(decrypted, file, preview)
- } else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
- videoView(player, decrypted, file, preview, duration)
- } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
- smallVideoViewEncrypted(file, defaultPreview)
- } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
- videoViewEncrypted(file, defaultPreview, duration)
- } else if let preview, let file {
- Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
- .onTapGesture {
- switch file.fileStatus {
- case .rcvInvitation, .rcvAborted:
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- case .rcvAccepted:
- switch file.fileProtocol {
- case .xftp:
- AlertManager.shared.showAlertMsg(
- title: "Waiting for video",
- message: "Video will be received when your contact completes uploading it."
- )
- case .smp:
- AlertManager.shared.showAlertMsg(
- title: "Waiting for video",
- message: "Video will be received when your contact is online, please wait or check later!"
- )
- case .local: ()
- }
- case .rcvTransfer: () // ?
- case .rcvComplete: () // ?
- case .rcvCancelled: () // TODO
- default: ()
- }
+ if let file, let preview {
+ if let urlDecrypted {
+ if smallView {
+ smallVideoView(urlDecrypted, file, preview)
+ } else if let player {
+ videoView(player, urlDecrypted, file, preview, duration)
}
+ } else if file.loaded {
+ if smallView {
+ smallVideoViewEncrypted(file, preview)
+ } else {
+ videoViewEncrypted(file, preview, duration)
+ }
+ } else {
+ Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
+ .simultaneousGesture(TapGesture().onEnded {
+ switch file.fileStatus {
+ case .rcvInvitation, .rcvAborted:
+ receiveFileIfValidSize(file: file, receiveFile: receiveFile)
+ case .rcvAccepted:
+ switch file.fileProtocol {
+ case .xftp:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for video",
+ message: "Video will be received when your contact completes uploading it."
+ )
+ case .smp:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for video",
+ message: "Video will be received when your contact is online, please wait or check later!"
+ )
+ case .local: ()
+ }
+ case .rcvTransfer: () // ?
+ case .rcvComplete: () // ?
+ case .rcvCancelled: () // TODO
+ default: ()
+ }
+ })
+ }
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
- if !smallView {
- Button {
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- } label: {
- playPauseIcon("play.fill")
- }
- } else if !file.showStatusIconInSmallView {
+ if !smallView || !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
- }
+ })
}
}
}
@@ -151,27 +151,26 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
- }
+ })
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
- Button {
- decrypt(file: file) {
- if urlDecrypted != nil {
- videoPlaying = true
- player?.play()
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ decrypt(file: file) {
+ if urlDecrypted != nil {
+ videoPlaying = true
+ player?.play()
+ }
}
- }
- } label: {
- playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
- }
- .disabled(!canBePlayed)
+ })
+ .disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
@@ -194,29 +193,30 @@ struct CIVideoView: View {
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
- .onTapGesture {
- switch player.timeControlStatus {
- case .playing:
- player.pause()
- videoPlaying = false
- case .paused:
- if canBePlayed {
- showFullScreenPlayer = true
+ .if(!blurred) { v in
+ v.simultaneousGesture(TapGesture().onEnded {
+ switch player.timeControlStatus {
+ case .playing:
+ player.pause()
+ videoPlaying = false
+ case .paused:
+ if canBePlayed {
+ showFullScreenPlayer = true
+ }
+ default: ()
}
- default: ()
- }
+ })
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
- Button {
- m.stopPreviousRecPlay = url
- player.play()
- } label: {
- playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
- }
- .disabled(!canBePlayed)
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
+ .simultaneousGesture(TapGesture().onEnded {
+ m.stopPreviousRecPlay = url
+ player.play()
+ })
+ .disabled(!canBePlayed)
}
}
fileStatusIcon()
@@ -235,7 +235,7 @@ struct CIVideoView: View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
- .onTapGesture {
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
@@ -256,7 +256,7 @@ struct CIVideoView: View {
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
- .onTapGesture {
+ .onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
@@ -354,14 +354,14 @@ struct CIVideoView: View {
case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError):
fileIcon("xmark", 10, 13)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError)
- }
+ })
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
- .onTapGesture {
+ .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):
@@ -375,14 +375,14 @@ struct CIVideoView: View {
case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError)
- }
+ })
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true)
- }
+ })
case .invalid: fileIcon("questionmark", 10, 13)
}
}
@@ -429,7 +429,7 @@ struct CIVideoView: View {
Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: {
- Button(action: { showFullScreenPlayer = false },
+ Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
label: {
Image(systemName: "multiply")
.resizable()
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index ff4378c715..47aee2a586 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -168,14 +168,14 @@ struct VoiceMessagePlayer: View {
case .sndCancelled: playbackButton()
case let .sndError(sndFileError):
fileStatusIcon("multiply", 14)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError)
- }
+ })
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true)
- }
+ })
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
@@ -184,14 +184,14 @@ struct VoiceMessagePlayer: View {
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError)
- }
+ })
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true)
- }
+ })
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
@@ -255,59 +255,29 @@ struct VoiceMessagePlayer: View {
}
}
- @ViewBuilder private func playbackButton() -> some View {
- if sizeMultiplier != 1 {
- switch playbackState {
- case .noPlayback:
- playPauseIcon("play.fill", theme.colors.primary)
- .onTapGesture {
- if let recordingSource = getLoadedFileSource(recordingFile) {
- startPlayback(recordingSource)
- }
- }
- case .playing:
- playPauseIcon("pause.fill", theme.colors.primary)
- .onTapGesture {
- audioPlayer?.pause()
- playbackState = .paused
- notifyStateChange()
- }
- case .paused:
- playPauseIcon("play.fill", theme.colors.primary)
- .onTapGesture {
- audioPlayer?.play()
- playbackState = .playing
- notifyStateChange()
- }
- }
- } else {
- switch playbackState {
- case .noPlayback:
- Button {
+ private func playbackButton() -> some View {
+ let icon = switch playbackState {
+ case .noPlayback: "play.fill"
+ case .playing: "pause.fill"
+ case .paused: "play.fill"
+ }
+ return playPauseIcon(icon, theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded { _ in
+ switch playbackState {
+ case .noPlayback:
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
- } label: {
- playPauseIcon("play.fill", theme.colors.primary)
- }
- case .playing:
- Button {
+ case .playing:
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
- } label: {
- playPauseIcon("pause.fill", theme.colors.primary)
- }
- case .paused:
- Button {
+ case .paused:
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
- } label: {
- playPauseIcon("play.fill", theme.colors.primary)
}
- }
- }
+ })
}
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
@@ -329,28 +299,14 @@ struct VoiceMessagePlayer: View {
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
- Group {
- if sizeMultiplier != 1 {
- playPauseIcon(icon, theme.colors.primary)
- .onTapGesture {
- Task {
- if let user = chatModel.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
- }
- }
+ playPauseIcon(icon, theme.colors.primary)
+ .simultaneousGesture(TapGesture().onEnded {
+ Task {
+ if let user = chatModel.currentUser {
+ await receiveFile(user: user, fileId: recordingFile.fileId)
}
- } else {
- Button {
- Task {
- if let user = chatModel.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
- }
- }
- } label: {
- playPauseIcon(icon, theme.colors.primary)
}
- }
- }
+ })
}
func notifyStateChange() {
@@ -430,6 +386,7 @@ struct VoiceMessagePlayer: View {
}
}
+@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
@@ -446,10 +403,12 @@ class VoiceItemState {
self.playbackTime = playbackTime
}
+ @inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
+ @inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
@@ -476,6 +435,7 @@ class VoiceItemState {
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -498,10 +458,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index 47a2cbb6cb..0b6f249b9c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -77,6 +77,7 @@ struct FramedCIVoiceView: View {
struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
- 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."))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, 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."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
}
.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 6da893d1d2..c9c9952688 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -12,9 +12,11 @@ import SimpleXChat
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
+ var scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
var preview: UIImage?
var maxWidth: CGFloat = .infinity
@State var msgWidth: CGFloat = 0
@@ -23,8 +25,6 @@ struct FramedItemView: View {
@State private var useWhiteMetaColor: Bool = false
@State var showFullScreenImage = false
@Binding var allowMenu: Bool
- @State private var showSecrets = false
- @State private var showQuoteSecrets = false
@State private var showFullscreenGallery: Bool = false
var body: some View {
@@ -57,18 +57,26 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
- .onTapGesture {
- if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
+ .simultaneousGesture(TapGesture().onEnded {
+ if let ci = im.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
- scrollModel.scrollToItem(id: ci.id)
+ scrollToItem(ci.id)
}
+ } else if let id = qi.itemId {
+ if (chatItem.isReport && im.secondaryIMFilter != nil) {
+ scrollToItemId = id
+ } else {
+ scrollToItem(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, msgContentView: framedMsgContentView)
+ ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
@@ -85,19 +93,19 @@ struct FramedItemView: View {
.overlay(DetermineWidth())
.accessibilityLabel("")
}
- }
+ }
.background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
- v.onTapGesture {
+ v.simultaneousGesture(TapGesture().onEnded {
AlertManager.shared.showAlert(
Alert(
title: Text(title),
message: Text(text)
)
)
- }
+ })
} else {
v
}
@@ -117,7 +125,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, _):
- CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
+ CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -155,7 +163,7 @@ struct FramedItemView: View {
case let .file(text):
ciFileView(chatItem, text)
case let .report(text, reason):
- ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red))
+ ciMsgContentView(chatItem, txtPrefix: reason.attrString)
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView(chatItem)
@@ -199,6 +207,7 @@ 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):
@@ -240,7 +249,8 @@ struct FramedItemView: View {
// if enable this always, size of the framed voice message item will be incorrect after end of playback
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
.frame(minWidth: msgWidth, alignment: .leading)
- .background(chatItemFrameContextColor(chatItem, theme))
+ .background(backgroundColor)
+ .environment(\.containerBackground, UIColor(backgroundColor))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@@ -254,7 +264,7 @@ struct FramedItemView: View {
VStack(alignment: .leading, spacing: 2) {
Text(sender)
.font(.caption)
- .foregroundColor(theme.colors.secondary)
+ .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
.lineLimit(1)
ciQuotedMsgTextView(qi, lines: 2)
}
@@ -266,14 +276,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 {
@@ -288,24 +296,27 @@ struct FramedItemView: View {
private func membership() -> GroupMember? {
switch chat.chatInfo {
- case let .group(groupInfo: groupInfo): return groupInfo.membership
+ case let .group(groupInfo: groupInfo, _): return groupInfo.membership
default: return nil
}
}
- @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> 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)
@@ -336,13 +347,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."
+ )
}
}
@@ -382,15 +392,16 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
struct FramedItemView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group{
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), 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)), 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)), 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)), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), 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 "), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}
@@ -398,17 +409,18 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), 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), 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), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), 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), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, 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), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, 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,/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), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
@@ -417,17 +429,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), 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)), 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)), 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)), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), 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)), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), 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)), 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)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, 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)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, 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)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), 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 044ee2a26d..f243a83142 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
@@ -13,8 +13,8 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
- @EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem
+ var scrollToItem: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
- scrollModel.scrollToItem(id: chatItem.id)
+ scrollToItem?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
@@ -126,7 +126,7 @@ struct FullScreenMediaView: View {
.scaledToFit()
}
}
- .onTapGesture { showView = false }
+ .onTapGesture { showView = false } // this is used in full screen view, onTapGesture works
}
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
index afeb88b05d..47a30f6cf3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View {
case .msgBadHash:
AlertManager.shared.showAlert(Alert(
title: Text("Bad message hash"),
- message: Text("The hash of the previous message is different.") + Text("\n") +
- Text(decryptErrorReason) + Text("\n") +
+ message: Text("The hash of the previous message is different.") + textNewLine +
+ Text(decryptErrorReason) + textNewLine +
Text("Please report it to the developers.")
))
case .msgBadId: msgBadIdAlert()
@@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View {
message: Text("""
The ID of the next message is incorrect (less or equal to the previous).
It can happen because of some bug or when the connection is compromised.
- """) + Text("\n") +
+ """) + textNewLine +
Text("Please report it to the developers.")
))
}
@@ -71,7 +71,7 @@ struct CIMsgError: View {
.padding(.vertical, 6)
.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 87a9b2ce61..c6a5d0353c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
var body: some View {
@@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View {
var mergedMarkedDeletedText: LocalizedStringKey {
if !revealed,
let ciCategory = chatItem.mergeCategory,
- var i = m.getChatItemIndex(chatItem) {
+ var i = m.getChatItemIndex(im, chatItem) {
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set = []
- while i < ItemsModel.shared.reversedChatItems.count,
- let ci = .some(ItemsModel.shared.reversedChatItems[i]),
+ while i < im.reversedChatItems.count,
+ let ci = .some(im.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider {
Group {
MarkedDeletedItemView(
chat: Chat.sampleData,
+ im: ItemsModel.shared,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index e9b6d0ba84..2a1b526893 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -11,51 +11,74 @@ import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
-private let noTyping = Text(verbatim: " ")
-
-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: Text? = nil
+ 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()
@@ -65,104 +88,374 @@ struct MsgContentView: View {
private func stopTyping() {
timer?.invalidate()
timer = nil
+ typingIdx = 0
}
- private func msgContentView() -> Text {
- var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix)
+ @inline(__always)
+ private func msgContentView() -> some View {
+ let r = messageText(
+ text,
+ formattedText,
+ textStyle: textStyle,
+ sender: sender,
+ mentions: mentions,
+ userMemberId: userMemberId,
+ showSecrets: showSecrets,
+ commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady,
+ 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, sendCommand: { cmd in sendCommandMsg(chat, cmd) })
}
- private func typingIndicator(_ recent: Bool) -> Text {
- return (recent ? typingIndicators[typingIdx] : noTyping)
- .font(.body.monospaced())
- .kerning(-2)
- .foregroundColor(theme.colors.secondary)
+ @inline(__always)
+ private func typingIndicator(_ recent: Bool) -> NSAttributedString {
+ recent && !typingIndicators.isEmpty
+ ? typingIndicators[typingIdx % 4]
+ : noTyping
}
+ @inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
- (rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
}
-func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text {
- let s = text
- var res: Text
-
- if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
- res = formatText(ft[0], preview, showSecret: showSecrets)
- var i = 1
- while i < ft.count {
- res = res + formatText(ft[i], preview, showSecret: showSecrets)
- i = i + 1
- }
- } else {
- res = Text(s)
- }
-
- if let i = icon {
- res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res
- }
-
- if let p = prefix {
- res = p + 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,
+ sendCommand: ((String) -> Void)? = nil,
+ centered: Bool = false,
+ smallFont: Bool = false
+) -> some View {
+ t.if(r.hasSecrets, transform: hiddenSecretsView)
+ .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, sendCommand: sendCommand, centered: centered, smallFont: smallFont)) }
}
-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: "")
+// smallFont parameter is used to pad height, otherwise CTFrameGetLines fails to see them as lines - it's needed if font is not .body
+@inline(__always)
+private func handleTextTaps(
+ _ s: NSAttributedString,
+ showSecrets: Binding>? = nil,
+ sendCommand: ((String) -> Void)? = nil,
+ centered: Bool,
+ smallFont: Bool
+) -> 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 paddedSize = smallFont ? CGSize(width: g.size.width, height: g.size.height + 1.0) : g.size
+ let path = CGPath(rect: CGRect(origin: .zero, size: paddedSize), 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)
+ var maxWidth: CGFloat = 0
+ if centered {
+ for line in lines {
+ let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
+ if bounds.width > maxWidth {
+ maxWidth = bounds.width
+ }
+ }
+ }
+ for i in 0 ..< lines.count {
+ let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
+ let offsetX = centered ? (maxWidth - bounds.width) / 2 : 0
+ if bounds.offsetBy(dx: origins[i].x + offsetX, dy: origins[i].y).contains(point) {
+ let relativePoint = centered ? CGPoint(x: point.x - origins[i].x - offsetX, y: point.y - origins[i].y) : point
+ index = CTLineGetStringIndexForPosition(lines[i], relativePoint)
+ break
+ }
+ }
+ }
+ if let index, let (uri, browser) = attributedStringLink(s, for: index) {
+ if browser {
+ openBrowserAlert(uri: uri)
+ } else if let url = URL(string: uri) {
+ UIApplication.shared.open(url)
+ } else {
+ showInvalidLinkAlert(uri)
+ }
+ }
+ })
+ }
+
+ func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
+ var linkURL: String?
+ 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? String {
+ linkURL = url
+ 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)
+ }
+ } else if let sendCommand, let cmd = attrs[commandAttrKey] as? String {
+ sendCommand(cmd)
+ }
+ 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")
+
+private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
+
+typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
+
+@inline(__always)
+func markdownText(
+ _ s: String,
+ textStyle: UIFont.TextStyle = .body,
+ sender: String? = nil,
+ preview: Bool = false,
+ mentions: [String: CIMention]? = nil,
+ userMemberId: String? = nil,
+ showSecrets: Set? = nil,
+ backgroundColor: Color
+) -> MsgTextResult {
+ messageText(
+ s,
+ parseSimpleXMarkdown(s),
+ textStyle: textStyle,
+ sender: sender,
+ preview: preview,
+ mentions: mentions,
+ userMemberId: userMemberId,
+ showSecrets: showSecrets,
+ commands: false,
+ backgroundColor: UIColor(backgroundColor)
+ )
+}
+
+
+func messageText(
+ _ text: String,
+ _ formattedText: [FormattedText]?,
+ textStyle: UIFont.TextStyle = .body,
+ sender: String?,
+ preview: Bool = false,
+ mentions: [String: CIMention]?,
+ userMemberId: String?,
+ showSecrets: Set?,
+ commands: Bool = false,
+ 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 link = t.hasPrefix("http://") || t.hasPrefix("https://")
+ ? t
+ : "https://" + t
+ attrs[linkAttrKey] = link
+ attrs[webLinkAttrKey] = true
+ handleTaps = true
+ }
+ case let .hyperLink(text, uri):
+ attrs = linkAttrs()
+ if let text { t = text }
+ if !preview {
+ attrs[linkAttrKey] = uri
+ attrs[webLinkAttrKey] = true
+ handleTaps = true
+ }
+ case let .simplexLink(text, linkType, simplexUri, smpHosts):
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = simplexUri
+ handleTaps = true
+ }
+ if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
+ res.append(NSAttributedString(string: s + " ", attributes: attrs))
+ italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
+ attrs[.font] = italic
+ t = viaHost(smpHosts)
+ }
+ case let .command(cmdStr):
+ snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
+ attrs[.font] = snippet
+ t = "/" + cmdStr
+ if !preview && commands {
+ attrs[.foregroundColor] = uiLinkColor
+ attrs[commandAttrKey] = t
+ handleTaps = true
+ }
+ 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] = "mailto:" + ft.text
+ handleTaps = true
+ }
+ case .phone:
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = "tel:" + t.replacingOccurrences(of: " ", with: "")
+ handleTaps = true
+ }
+ case .unknown: ()
+ case .none: ()
+ }
+ res.append(NSAttributedString(string: t, attributes: attrs))
+ }
+ } else {
+ res.append(NSMutableAttributedString(string: text, attributes: plain))
+ }
+
+ return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
+
+ func linkAttrs() -> [NSAttributedString.Key: Any] {
+ link = link ?? [
+ .font: font,
+ .foregroundColor: uiLinkColor,
+ .underlineStyle: NSUnderlineStyle.single.rawValue
+ ]
+ return link!
+ }
+}
+
+@inline(__always)
+private func mentionText(_ name: String) -> String {
+ name.contains(" @") ? "@'\(name)'" : "@\(name)"
}
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
- linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
+ linkType.description + " " + viaHost(smpHosts)
+}
+
+func viaHost(_ smpHosts: [String]) -> String {
+ "(via \(smpHosts.first ?? "?"))"
}
struct MsgContentView_Previews: PreviewProvider {
@@ -172,9 +465,9 @@ struct MsgContentView_Previews: PreviewProvider {
chat: Chat.sampleData,
text: chatItem.text,
formattedText: chatItem.formattedText,
+ textStyle: .body,
sender: chatItem.memberDisplayName,
- meta: chatItem.meta,
- showSecrets: false
+ meta: chatItem.meta
)
.environmentObject(Chat.sampleData)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
index 587957cd5d..dfc620c402 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
@@ -41,7 +41,7 @@ struct ChatItemForwardingView: View {
.alert(item: $alert) { $0.alert }
}
- @ViewBuilder private func forwardListView() -> some View {
+ private func forwardListView() -> some View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index 62ea607d27..87c6ba92f8 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -14,6 +14,7 @@ struct ChatItemInfoView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var ci: ChatItem
+ var userMemberId: String?
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil
@@ -130,9 +131,9 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func details() -> some View {
+ private func details() -> some View {
let meta = ci.meta
- VStack(alignment: .leading, spacing: 16) {
+ return VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
@@ -196,7 +197,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func historyTab() -> some View {
+ private func historyTab() -> some View {
GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84
ScrollView {
@@ -226,12 +227,13 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
+ private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
+ let backgroundColor = chatItemFrameColor(ci, theme)
+ return VStack(alignment: .leading, spacing: 4) {
+ textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(ci, theme))
+ .background(backgroundColor)
.modifier(ChatItemClipped())
.contextMenu {
if itemVersion.msgContent.text != "" {
@@ -256,9 +258,9 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
- @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
+ @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
if text != "" {
- TextBubble(text: text, formattedText: formattedText, sender: sender)
+ TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
} else {
Text("no text")
.italic()
@@ -271,14 +273,18 @@ struct ChatItemInfoView: View {
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
- @State private var showSecrets = false
+ var mentions: [String: CIMention]?
+ var userMemberId: String?
+ var backgroundColor: Color
+ @State private var showSecrets: Set = []
var body: some View {
- toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
+ let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
+ return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
}
}
- @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
+ private func quoteTab(_ qi: CIQuote) -> some View {
GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84
ScrollView {
@@ -296,9 +302,10 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- textBubble(qi.text, qi.formattedText, qi.getSender(nil))
+ private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
+ let backgroundColor = quotedMsgFrameColor(qi, theme)
+ return VStack(alignment: .leading, spacing: 4) {
+ textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
@@ -331,7 +338,7 @@ struct ChatItemInfoView: View {
: theme.appColors.receivedMessage
}
- @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
+ private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
@@ -351,8 +358,9 @@ struct ChatItemInfoView: View {
Button {
Task {
await MainActor.run {
- ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
- dismiss()
+ ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
+ dismiss()
+ }
}
}
} label: {
@@ -368,7 +376,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
+ private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6)
@@ -399,7 +407,7 @@ struct ChatItemInfoView: View {
}
}
- @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
@@ -414,7 +422,7 @@ struct ChatItemInfoView: View {
.frame(maxHeight: .infinity, alignment: .top)
}
- @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
@@ -548,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 ebbc55a932..5f48c18881 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -18,6 +18,10 @@ extension EnvironmentValues {
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 }
@@ -27,26 +31,40 @@ extension EnvironmentValues {
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
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
+ var scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
var maxWidth: CGFloat = .infinity
@Binding var allowMenu: Bool
init(
chat: Chat,
+ im: ItemsModel,
chatItem: ChatItem,
+ scrollToItem: @escaping (ChatItem.ID) -> Void,
+ scrollToItemId: Binding = .constant(nil),
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
allowMenu: Binding = .constant(false)
) {
self.chat = chat
+ self.im = im
self.chatItem = chatItem
+ self.scrollToItem = scrollToItem
+ _scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
_allowMenu = allowMenu
}
@@ -54,14 +72,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
- MarkedDeletedItemView(chat: chat, chatItem: chatItem)
+ MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
- ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
+ ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -89,7 +107,10 @@ struct ChatItemView: View {
}()
return FramedItemView(
chat: chat,
+ im: im,
chatItem: chatItem,
+ scrollToItem: scrollToItem,
+ scrollToItemId: $scrollToItemId,
preview: preview,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
@@ -104,6 +125,7 @@ struct ChatItemContentView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -127,7 +149,9 @@ struct ChatItemContentView: View {
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
+ case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .rcvGroupEvent: eventItemView()
+ case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
@@ -136,7 +160,7 @@ struct ChatItemContentView: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
- CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
+ CIChatFeatureView(chat: chat, im: im, 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)
@@ -148,6 +172,7 @@ struct ChatItemContentView: View {
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
+ case .chatBanner: EmptyView()
case let .invalidJSON(json): CIInvalidJSONView(json: json)
}
}
@@ -168,6 +193,13 @@ struct ChatItemContentView: View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
}
+ private func pendingReviewEventItemText() -> Text {
+ Text(chatItem.content.text)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .fontWeight(.bold)
+ }
+
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
@@ -183,7 +215,7 @@ struct ChatItemContentView: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
- CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
+ CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -210,16 +242,21 @@ struct ChatItemContentView: View {
}
private func directE2EEInfoText(_ info: E2EEInfo) -> Text {
- 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(theme.colors.secondary)
- .fontWeight(.light)
- : e2eeInfoNoPQText()
+ if let pqEnabled = info.pqEnabled {
+ pqEnabled
+ ? e2eeInfoText("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
+ : e2eeInfoNoPQText()
+ } else {
+ e2eeInfoText("Messages are protected by **end-to-end encryption**.")
+ }
}
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.")
+ e2eeInfoText("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
+ }
+
+ private func e2eeInfoText(_ s: LocalizedStringKey) -> Text {
+ Text(s)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
@@ -243,16 +280,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group{
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
@@ -262,57 +300,72 @@ struct ChatItemView_Previews: PreviewProvider {
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
- )
+ ),
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
quotedItem: nil,
file: nil
- )
+ ),
+ scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
- )
+ ),
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
- )
+ ),
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
content: ciFeatureContent,
quotedItem: nil,
file: nil
- )
+ ),
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
}
.environment(\.revealed, true)
diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
new file mode 100644
index 0000000000..93ecf870eb
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
@@ -0,0 +1,516 @@
+//
+// 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,
+ _ im: ItemsModel,
+ _ pagination: ChatPagination,
+ _ 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, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, 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 = im.chatState.unreadAfterItemId
+
+ let oldItems = Array(im.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 im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil {
+ chatModel.addChat(chat)
+ }
+ await MainActor.run {
+ im.reversedChatItems = chat.chatItems.reversed()
+ if im.secondaryIMFilter == nil {
+ chatModel.updateChatInfo(chat.chatInfo)
+ }
+ im.chatState.splits = newSplits
+ if !chat.chatItems.isEmpty {
+ im.chatState.unreadAfterItemId = chat.chatItems.last!.id
+ }
+ im.chatState.totalAfter = navInfo.afterTotal
+ im.chatState.unreadTotal = chat.chatStats.unreadCount
+ im.chatState.unreadAfter = navInfo.afterUnread
+ im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+
+ im.preloadState.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, im.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 {
+ im.reversedChatItems = newReversed
+ im.chatState.splits = modifiedSplits.newSplits
+ im.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, im.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 {
+ im.reversedChatItems = newReversed
+ im.chatState.splits = newSplits
+ im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new)
+ // loading clear bottom area, updating number of unread items after the newest loaded item
+ if indexToAddIsLast {
+ im.chatState.unreadAfterNewestLoaded -= unreadInLoaded
+ }
+ }
+ case .around:
+ var newSplits: [Int64]
+ if openAroundItemId == nil {
+ newItems.append(contentsOf: oldItems)
+ newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.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 {
+ im.reversedChatItems = newReversed
+ im.chatState.splits = orderedSplits
+ im.chatState.unreadAfterItemId = chat.chatItems.last!.id
+ im.chatState.totalAfter = navInfo.afterTotal
+ im.chatState.unreadTotal = chat.chatStats.unreadCount
+ im.chatState.unreadAfter = navInfo.afterUnread
+
+ if let openAroundItemId {
+ im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ if im.secondaryIMFilter == nil {
+ ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel?
+ ChatModel.shared.chatId = chat.id
+ }
+ } else {
+ // no need to set it, count will be wrong
+ // chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ }
+ im.preloadState.clear()
+ }
+ case .last:
+ newItems.append(contentsOf: oldItems)
+ let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits)
+ newItems.append(contentsOf: chat.chatItems)
+ let items = newItems
+ await MainActor.run {
+ im.reversedChatItems = items.reversed()
+ im.chatState.splits = newSplits
+ if im.secondaryIMFilter == nil {
+ chatModel.updateChatInfo(chat.chatInfo)
+ }
+ im.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..5f2102b8bc
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
@@ -0,0 +1,457 @@
+//
+// 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 im: ItemsModel
+ 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(_ im: ItemsModel, _ revealedItems: Set) -> MergedItems {
+ if im.reversedChatItems.isEmpty {
+ return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:])
+ }
+
+ let unreadCount = im.chatState.unreadTotal
+
+ let unreadAfterItemId = im.chatState.unreadAfterItemId
+ let itemSplits = im.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 - im.chatState.unreadAfterNewestLoaded
+ var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
+ var lastRangeInReversedForMergedItems: BoxedValue>? = nil
+ var recent: MergedItem? = nil
+ while index < im.reversedChatItems.count {
+ let item = im.reversedChatItems[index]
+ let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil
+ let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil
+ let category = item.mergeCategory
+ let itemIsSplit = itemSplits.contains(item.id)
+
+ if item.id == unreadAfterItemId {
+ unreadBefore = unreadCount - im.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: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
+ }
+ unclosedSplitIndex = index
+ unclosedSplitIndexInParent = visibleItemIndexInParent
+ } else if index + 1 == im.reversedChatItems.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: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
+ }
+ indexInParentItems[item.id] = visibleItemIndexInParent
+ index += 1
+ }
+ return MergedItems(
+ im: im,
+ 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 {
+ // 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(_ im: ItemsModel, _ 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 = im.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 im.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..2fb1c3fb35
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
@@ -0,0 +1,174 @@
+//
+// 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, _ im: ItemsModel) 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, im, ChatPagination.last(count: 50))
+ await MainActor.run {
+ loadingMoreItems.wrappedValue = false
+ loadingBottomItems.wrappedValue = false
+ }
+}
+
+func preloadIfNeeded(
+ _ im: ItemsModel,
+ _ allowLoadMoreItems: Binding,
+ _ ignoreLoadingRequests: Binding,
+ _ listState: EndlessScrollView.ListState,
+ _ mergedItems: BoxedValue,
+ loadItems: @escaping (Bool, ChatPagination) async -> Bool,
+ loadLastItems: @escaping () async -> Void
+) {
+ let state = im.preloadState
+ 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(im, 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 && !im.lastItemsLoaded {
+ await loadLastItems()
+ }
+ }
+ } else if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
+ state.preloading = true
+ Task {
+ defer { state.preloading = false }
+ await loadLastItems()
+ }
+ }
+}
+
+func preloadItems(
+ _ im: ItemsModel,
+ _ 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] = im.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 == im.reversedChatItems.count && firstItemIdWas == im.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] = im.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 baceb5b4ab..709758655f 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -15,40 +15,59 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
- @ObservedObject var im = ItemsModel.shared
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
+ @State var revealedItems: Set = Set()
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
- @StateObject private var scrollModel = ReverseListScrollModel()
+ @ObservedObject var im: ItemsModel
+ @State var mergedItems: BoxedValue
+ @State var floatingButtonModel: FloatingButtonModel
+ @Binding var scrollToItemId: ChatItem.ID?
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
+ @State private var selectedRange = NSRange()
@State private var keyboardVisible = false
+ @State private var keyboardHiddenDate = Date.now
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
- @State private var loadingItems = false
- @State private var firstPage = false
- @State private var revealedChatItem: ChatItem?
- @State private var searchMode = false
+ @State private var loadingMoreItems = false
+ @State private var loadingTopItems = false
+ @State private var requestedTopScroll = false
+ @State private var loadingBottomItems = false
+ @State private var requestedBottomScroll = false
+ @State private var showSearch = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
- @State private var groupLink: String?
+ @State private var groupLink: GroupLink?
@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 showUserSupportChatSheet = false
+ @State private var showCommandsMenu = false
+ @State private var supportChatMemberInfoLinkActive = false
+
+ @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero)
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
+
var body: some View {
if #available(iOS 16.0, *) {
viewBody
@@ -59,44 +78,97 @@ struct ChatView: View {
}
}
- @ViewBuilder
private var viewBody: some View {
let cInfo = chat.chatInfo
- ZStack {
+ let memberSupportChat: (groupInfo: GroupInfo, member: GroupMember?)? =
+ if case let .group(groupInfo, .memberSupport(member)) = cInfo {
+ (groupInfo, member)
+ } else {
+ nil
+ }
+ let userMemberKnockingChat = memberSupportChat?.groupInfo.membership.memberPending == true
+ 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
+ .if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in
view.modifier(
ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor)
)
}
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
- chatItemsList()
- FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
+ if userMemberKnockingChat {
+ ZStack(alignment: .top) {
+ chatItemsList()
+ userMemberKnockingTitleBar()
+ }
+ } else {
+ chatItemsList()
+ }
+ if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
+ GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
+ }
+ if !chat.chatInfo.menuCommands.isEmpty {
+ CommandsMenuView(chat: chat, composeState: $composeState, selectedRange: $selectedRange, showCommandsMenu: $showCommandsMenu)
+ }
+ FloatingButtons(im: im, 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, revealedItems)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ }
+ )
+ }
+ if let connectInProgressText = connectProgressManager.showConnectProgress {
+ connectInProgressView(connectInProgressText)
+ }
+ if let connectingText {
+ Text(connectingText)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top)
}
- connectingText()
if selectedChatItems == nil {
+ let reason = chat.chatInfo.userCantSendReason
+ let composeEnabled = (
+ chat.chatInfo.sendMsgEnabled ||
+ (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message
+ (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request
+ )
ComposeView(
chat: chat,
+ im: im,
composeState: $composeState,
- keyboardVisible: $keyboardVisible
+ showCommandsMenu: $showCommandsMenu,
+ keyboardVisible: $keyboardVisible,
+ keyboardHiddenDate: $keyboardHiddenDate,
+ selectedRange: $selectedRange,
+ disabledText: reason?.composeLabel
)
- .disabled(!cInfo.sendMsgEnabled)
+ .disabled(!composeEnabled)
+ .if(!composeEnabled) { v in
+ v.disabled(true).onTapGesture {
+ AlertManager.shared.showAlertMsg(
+ title: "You can't send messages!",
+ message: reason?.alertMessage
+ )
+ }
+ }
} else {
SelectedItemsBottomToolbar(
- chatItems: ItemsModel.shared.reversedChatItems,
+ im: im,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
allowToDeleteSelectedMessagesForAll = forAll
showDeleteSelectedMessages = true
},
+ archiveItems: {
+ showArchiveSelectedReports = true
+ },
moderateItems: {
- if case let .group(groupInfo) = chat.chatInfo {
+ if case let .group(groupInfo, _) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
},
@@ -104,15 +176,44 @@ struct ChatView: View {
)
}
}
+ if im.showLoadingProgress == chat.id {
+ ProgressView().scaleEffect(2)
+ }
+ if case let .group(groupInfo, _) = chat.chatInfo,
+ case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
+ case let .memberSupport(groupMember_) = groupScopeInfo,
+ let groupMember = groupMember_ {
+ NavigationLink(isActive: $supportChatMemberInfoLinkActive) {
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: GMember(groupMember),
+ scrollToItemId: $scrollToItemId,
+ openedFromSupportChat: true
+ )
+ .navigationBarHidden(false)
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ supportChatMemberInfoLinkActive = false
+ })
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
}
.safeAreaInset(edge: .top) {
VStack(spacing: .zero) {
- if searchMode { searchToolbar() }
+ if showSearch { searchToolbar() }
Divider()
}
.background(ToolbarMaterial.material(toolbarMaterial))
}
- .navigationTitle(cInfo.chatViewName)
+ .navigationTitle(
+ memberSupportChat == nil
+ ? cInfo.chatViewName
+ : memberSupportChat?.member?.chatViewName ?? NSLocalizedString("Chat with admins", comment: "chat toolbar")
+ )
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.environmentObject(theme)
@@ -130,24 +231,37 @@ struct ChatView: View {
}
}
}
- .appSheet(item: $selectedMember) { member in
- Group {
- if case let .group(groupInfo) = chat.chatInfo {
- GroupMemberInfoView(
- groupInfo: groupInfo,
- chat: chat,
- groupMember: member,
- navigation: true
- )
+ .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) {
+ Button("For me", role: .destructive) {
+ if let selected = selectedChatItems {
+ archiveReports(chat, 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, selected.sorted(), true, deletedSelectedMessages)
+ }
+ }
+ }
+ }
+ .appSheet(item: $selectedMember, onDismiss: {
+ chatModel.secondaryIM = nil
+ }) { member in
+ if case let .group(groupInfo, _) = chat.chatInfo {
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: member,
+ scrollToItemId: $scrollToItemId,
+ 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)
- }
+ if case let .group(groupInfo, _) = cInfo {
+ AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
.sheet(isPresented: Binding(
@@ -166,44 +280,123 @@ struct ChatView: View {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
+ .appSheet(
+ isPresented: $showUserSupportChatSheet,
+ onDismiss: {
+ if chat.chatInfo.groupInfo?.membership.memberPending ?? false {
+ chatModel.chatId = nil
+ }
+ }
+ ) {
+ if let groupInfo = cInfo.groupInfo {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ }
+ }
.onAppear {
+ ConnectProgressManager.shared.cancelConnectProgress()
+ 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
+ }
+ }
+ }
+ }
+ // if this is the main chat of the group with the pending member (knocking)
+ if case let .group(groupInfo, nil) = chat.chatInfo,
+ groupInfo.membership.memberPending {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) {
+ showUserSupportChatSheet = true
+ chatModel.secondaryPendingInviteeChatOpened = true
+ }
+ }
+ }
+ .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { secondaryChatOpened in
+ if secondaryChatOpened {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) {
+ showUserSupportChatSheet = true
+ }
+ }
}
.onChange(of: chatModel.chatId) { cId in
+ ConnectProgressManager.shared.cancelConnectProgress()
showChatInfoSheet = false
selectedChatItems = nil
- scrollModel.scrollToBottom()
+ revealedItems = Set()
stopAudioPlayer()
if let cId {
if let c = chatModel.getChat(cId) {
chat = c
}
+ scrollView.listState.onUpdateListener = onChatItemsUpdated
initChatView()
theme = buildTheme()
+ closeSearch()
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+
+ if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
+ scrollView.scrollToItem(index)
+ } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
+ scrollView.scrollToItem(unreadIndex)
+ } else {
+ scrollView.scrollToBottom()
+ }
+ if chatModel.openAroundItemId != nil {
+ chatModel.openAroundItemId = nil
+ }
} else {
dismiss()
}
}
- .onChange(of: revealedChatItem) { _ in
- NotificationCenter.postReverseListNeedsLayout()
- }
- .onChange(of: im.isLoading) { isLoading in
- if !isLoading,
- im.reversedChatItems.count <= loadItemsPerPage,
- filtered(im.reversedChatItems).count < 10 {
- loadChatItems(chat.chatInfo)
+ .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in
+ if im.secondaryIMFilter != nil && !opened {
+ Task {
+ try? await Task.sleep(nanoseconds: 650_000000)
+ dismiss()
+ }
+ }
+ }
+ .onChange(of: chatModel.openAroundItemId) { openAround in
+ if let openAround {
+ closeSearch()
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ chatModel.openAroundItemId = nil
+
+ if let index = mergedItems.boxedValue.indexInParentItems[openAround] {
+ scrollView.scrollToItem(index)
+ }
+
+ // this may already being loading because of changed chat id (see .onChange(of: chat.id)
+ if !loadingBottomItems {
+ allowLoadMoreItems = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ allowLoadMoreItems = true
+ }
+ }
}
}
- .environmentObject(scrollModel)
.onDisappear {
+ ConnectProgressManager.shared.cancelConnectProgress()
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 = []
+ chatModel.chatAgentConnId = nil
+ chatModel.chatSubStatus = nil
+ im.reversedChatItems = []
+ im.chatState.clear()
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
@@ -216,148 +409,280 @@ struct ChatView: View {
}
.toolbar {
ToolbarItem(placement: .principal) {
- if selectedChatItems != nil {
- SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
- } else if case let .direct(contact) = cInfo {
- Button {
- Task {
- showChatInfoSheet = true
- }
- } label: {
- ChatInfoToolbar(chat: chat)
- }
- .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 chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
- } label: {
- ChatInfoToolbar(chat: chat)
- .tint(theme.colors.primary)
- }
- .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
- GroupChatInfoView(
- chat: chat,
- groupInfo: Binding(
- get: { groupInfo },
- set: { gInfo in
- chat.chatInfo = .group(groupInfo: gInfo)
- chat.created = Date.now
- }
- ),
- onSearch: { focusSearch() },
- localAlias: groupInfo.localAlias
- )
- }
- } else if case .local = cInfo {
- ChatInfoToolbar(chat: chat)
+ if im.secondaryIMFilter == nil {
+ primaryPrincipalToolbarContent()
+ } else if !userMemberKnockingChat { // no toolbar while knocking chat, it's unstable on sheet
+ secondaryPrincipalToolbarContent()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
- let isLoading = im.isLoading && im.showLoadingProgress
- if selectedChatItems != nil {
- Button {
- withAnimation {
- selectedChatItems = nil
- }
- } 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)
- }
- }
- Menu {
- if !isLoading {
- if callsPrefEnabled && chatModel.activeCall == nil {
- Button {
- CallController.shared.startCall(contact, .video)
- } label: {
- Label("Video call", systemImage: "video")
- }
- .disabled(!contact.ready || !contact.active)
- }
- searchButton()
- ToggleNtfsButton(chat: chat)
- .disabled(!contact.ready || !contact.active)
- }
- } label: {
- Image(systemName: "ellipsis")
- .tint(isLoading ? Color.clear : nil)
- .overlay { if isLoading { ProgressView() } }
- }
- }
- case let .group(groupInfo):
- 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 {
- if !isLoading {
- searchButton()
- ToggleNtfsButton(chat: chat)
- }
- } label: {
- Image(systemName: "ellipsis")
- .tint(isLoading ? Color.clear : nil)
- .overlay { if isLoading { ProgressView() } }
- }
- }
- case .local:
- searchButton()
- default:
- EmptyView()
+ if im.secondaryIMFilter == nil {
+ primaryTrailingToolbarContent()
+ } else if !userMemberKnockingChat {
+ secondaryTrailingToolbarContent()
+ }
+ }
+ }
+ .if(im.secondaryIMFilter == nil) { v in
+ v.onChange(of: scrollToItemId) { itemId in
+ if let itemId = itemId {
+ dismissAllSheets(animated: false) {
+ scrollToItem(itemId)
+ scrollToItemId = nil
}
}
}
}
}
-
+
+ private func connectInProgressView(_ s: String) -> some View {
+ VStack(spacing: 0) {
+ Divider()
+
+ HStack(spacing: 12) {
+ ProgressView()
+ Text(s)
+
+ Spacer()
+
+ Button {
+ ConnectProgressManager.shared.cancelConnectProgress()
+ } label: {
+ Image(systemName: "multiply")
+ }
+ .tint(theme.colors.primary)
+ }
+ .padding(12)
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func primaryPrincipalToolbarContent() -> some View {
+ let cInfo = chat.chatInfo
+ if selectedChatItems != nil {
+ SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
+ } else if case let .direct(contact) = cInfo {
+ Button {
+ Task {
+ showChatInfoSheet = true
+ }
+ } label: {
+ ChatInfoToolbar(chat: chat)
+ }
+ .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 chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
+ } label: {
+ ChatInfoToolbar(chat: chat)
+ .tint(theme.colors.primary)
+ }
+ .appSheet(isPresented: $showChatInfoSheet, onDismiss: {
+ chatModel.secondaryIM = nil
+ theme = buildTheme()
+ }) {
+ GroupChatInfoView(
+ chat: chat,
+ groupInfo: Binding(
+ get: { groupInfo },
+ set: { gInfo in
+ chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil)
+ chat.created = Date.now
+ }
+ ),
+ scrollToItemId: $scrollToItemId,
+ onSearch: { focusSearch() },
+ localAlias: groupInfo.localAlias
+ )
+ }
+ } else if case .local = cInfo {
+ ChatInfoToolbar(chat: chat)
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func primaryTrailingToolbarContent() -> some View {
+ let cInfo = chat.chatInfo
+ if selectedChatItems != nil {
+ Button {
+ withAnimation {
+ selectedChatItems = nil
+ }
+ } 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)
+ }
+ }
+ 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")
+ }
+ }
+ 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()
+ }
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func secondaryPrincipalToolbarContent() -> some View {
+ if selectedChatItems != nil {
+ SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
+ } else {
+ switch im.secondaryIMFilter {
+ case let .groupChatScopeContext(groupScopeInfo):
+ switch groupScopeInfo {
+ case let .memberSupport(groupMember_):
+ if let groupMember = groupMember_ {
+ Button {
+ supportChatMemberInfoLinkActive = true
+ } label: {
+ MemberSupportChatToolbar(groupMember: groupMember)
+ }
+ } else {
+ textChatToolbar("Chat with admins")
+ }
+ case .reports:
+ textChatToolbar("Member reports")
+ }
+ case let .msgContentTagContext(contentTag):
+ switch contentTag {
+ case .report:
+ textChatToolbar("Member reports")
+ default:
+ EmptyView()
+ }
+ case .none:
+ EmptyView()
+ }
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func secondaryTrailingToolbarContent() -> some View {
+ if selectedChatItems != nil {
+ Button {
+ withAnimation {
+ selectedChatItems = nil
+ }
+ } label: {
+ Text("Cancel")
+ }
+ } else {
+ searchButton()
+ }
+ }
+
+ @inline(__always)
+ private func userMemberKnockingTitleBar() -> some View {
+ VStack(spacing: 0) {
+ Text("Chat with admins")
+ .font(.headline)
+ .foregroundColor(theme.colors.onBackground)
+ .padding(.top, 8)
+ .padding(.bottom, 14)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ Divider()
+ }
+ }
+
+ func textChatToolbar(_ text: LocalizedStringKey) -> some View {
+ Text(text)
+ .font(.headline)
+ .lineLimit(1)
+ .foregroundColor(theme.colors.onBackground)
+ .frame(width: 220)
+ }
+
private func initChatView() {
let cInfo = chat.chatInfo
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
- if case .active = scenePhase,
- case let .direct(contact) = cInfo {
- Task {
- do {
- let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId)
- await MainActor.run {
- if let s = stats {
- chatModel.updateContactConnectionStats(contact, s)
+ if case .active = scenePhase {
+ if case let .direct(contact) = cInfo {
+ Task {
+ do {
+ let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId)
+ await MainActor.run {
+ if let s = stats {
+ chatModel.updateContactConnectionStats(contact, s)
+ if let conn = contact.activeConn {
+ chatModel.chatAgentConnId = conn.agentConnId
+ chatModel.chatSubStatus = s.subStatus
+ }
+ }
}
+ } catch let error {
+ logger.error("apiContactInfo error: \(responseError(error))")
+ }
+ }
+ } else {
+ Task {
+ await MainActor.run {
+ chatModel.chatAgentConnId = nil
+ chatModel.chatSubStatus = nil
}
- } catch let error {
- logger.error("apiContactInfo error: \(responseError(error))")
}
}
}
@@ -370,7 +695,40 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false)
}
}
- ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
+ floatingButtonModel.updateOnListChange(scrollView.listState)
+ }
+
+ private func scrollToItem(_ 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 = im.reversedChatItems.count
+ let triedToLoad = await loadChatItems(chat, pagination)
+ if !triedToLoad {
+ return
+ }
+ var repeatsLeft = 50
+ while oldSize == im.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(im.reversedChatItems.count - 1, index))
+ await MainActor.run { animatedScrollingInProgress = false }
+ }
+ }
+ }
+ } catch {
+ logger.error("Error scrolling to item: \(error)")
+ }
+ }
}
private func searchToolbar() -> some View {
@@ -394,16 +752,14 @@ struct ChatView: View {
.cornerRadius(10.0)
Button ("Cancel") {
- searchText = ""
- searchMode = false
- searchFocussed = false
- Task { await loadChat(chat: chat) }
+ closeSearch()
+ searchTextChanged("")
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
-
+
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
}
@@ -421,204 +777,427 @@ struct ChatView: View {
.map { $0.element }
}
-
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
- let mergedItems = filtered(im.reversedChatItems)
return GeometryReader { g in
- ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in
- let 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,
- chatItem: ci,
- maxWidth: maxWidth,
- composeState: $composeState,
- selectedMember: $selectedMember,
- showChatInfoSheet: $showChatInfoSheet,
- revealedChatItem: $revealedChatItem,
- selectedChatItems: $selectedChatItems,
- forwardedChatItems: $forwardedChatItems
- )
+ //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
+ }
+ return Group {
+ if case .chatBanner = ci.content {
+ VStack {
+ ChatBannerView(chat: $chat)
+ .padding(.bottom, 90)
+ .padding(.top, 8)
+
+ let listItem = mergedItem.newest()
+ if let prevItem = listItem.prevItem {
+ DateSeparator(date: prevItem.meta.itemTs).padding(8)
+ }
+ }
+ } else {
+ 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
+ ChatItemWithMenu(
+ im: im,
+ chat: $chat,
+ index: index,
+ isLastItem: index == mergedItems.boxedValue.items.count - 1,
+ chatItem: ci,
+ scrollToItem: scrollToItem,
+ 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
- } loadPage: {
- loadChatItems(cInfo)
}
- .opacity(ItemsModel.shared.isLoading ? 0 : 1)
- .padding(.vertical, -InvertedTableView.inset)
- .onTapGesture { hideKeyboard() }
- .onChange(of: searchText) { _ in
- Task { await loadChat(chat: chat, search: searchText) }
+ .onAppear {
+ if !im.isLoading {
+ updateWithInitiallyLoadedItems()
+ }
}
- .onChange(of: im.itemAdded) { added in
- if added {
+ .onChange(of: im.isLoading) { loading in
+ if !loading {
+ updateWithInitiallyLoadedItems()
+ }
+ }
+ .onChange(of: im.reversedChatItems) { items in
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
+ scrollView.updateItems(mergedItems.boxedValue.items)
+ if im.itemAdded {
im.itemAdded = false
- if FloatingButtonModel.shared.isReallyNearBottom {
- scrollModel.scrollToBottom()
+ if scrollView.listState.firstVisibleItemIndex < 2 {
+ scrollView.scrollToBottomAnimated()
+ } else {
+ scrollView.scroll(by: 34)
}
}
}
+ .onChange(of: revealedItems) { revealed in
+ mergedItems.boxedValue = MergedItems.create(im, revealed)
+ 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)
+ }
+ }
}
}
- @ViewBuilder private func connectingText() -> some View {
- if case let .direct(contact) = chat.chatInfo,
- !contact.sndReady,
- contact.active,
- !contact.nextSendGrpInv {
- Text("connecting…")
- .font(.caption)
- .foregroundColor(theme.colors.secondary)
- .padding(.top)
- } else {
- EmptyView()
+ struct ChatBannerView: View {
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
+ @Binding @ObservedObject var chat: Chat
+ @State private var showSecrets: Set = []
+
+ var body: some View {
+ let v = VStack(spacing: 8) {
+ ChatInfoImage(chat: chat, size: alertProfileImageSize)
+
+ Text(chat.chatInfo.displayName)
+ .font(.title3)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 240)
+
+ let fullName = chat.chatInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if fullName != "" && fullName != chat.chatInfo.displayName && fullName != chat.chatInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
+ Text(chat.chatInfo.fullName)
+ .font(.subheadline)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 260)
+ }
+
+ if let shortDescr = chat.chatInfo.shortDescr {
+ let r = markdownText(shortDescr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
+ .multilineTextAlignment(.center)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal)
+ }
+
+ if let chatContext {
+ Text(chatContext)
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top, 8)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(theme.appColors.receivedMessage)
+ .clipShape(RoundedRectangle(cornerRadius: msgRectMaxRadius * roundness))
+ if let (label, connLink) = chatAddress() {
+ v.contextMenu {
+ Button {
+ let shareItems: [Any] = [connLink]
+ showShareSheet(items: shareItems)
+ } label: {
+ Label(label, systemImage: "square.and.arrow.up")
+ }
+ }
+ .padding(.horizontal)
+ } else {
+ v.padding(.horizontal)
+ }
+
+ }
+
+ func chatAddress() -> (label: LocalizedStringKey, connLink: String)? {
+ switch chat.chatInfo {
+ case let .direct(contact):
+ if !contact.nextConnectPrepared && !contact.nextAcceptContactRequest {
+ let connLink: String? = if let pct = contact.preparedContact, case .con = pct.uiConnLinkType {
+ pct.connLinkToConnect.simplexChatUri()
+ } else {
+ contact.profile.contactLink
+ }
+ if let connLink {
+ return ("SimpleX address", connLink)
+ }
+ }
+ case let .group(groupInfo, _):
+ if !groupInfo.nextConnectPrepared {
+ if let pg = groupInfo.preparedGroup {
+ let connLink = pg.connLinkToConnect.simplexChatUri()
+ switch groupInfo.businessChat?.chatType {
+ case .none: return ("Group link", connLink)
+ case .business: return ("Business address", connLink)
+ default: ()
+ }
+ }
+ }
+ default: ()
+ }
+ return nil
+ }
+
+ var chatContext: LocalizedStringKey? {
+ switch chat.chatInfo {
+ case let .direct(contact):
+ if contact.nextConnectPrepared, let linkType = contact.preparedContact?.uiConnLinkType {
+ switch linkType {
+ case .inv:
+ "Tap Connect to chat"
+ case .con:
+ contact.isBot ? "Tap Connect to use bot" : "Tap Connect to send request"
+ }
+ } else if contact.nextAcceptContactRequest {
+ "Accept contact request"
+ } else if case .bot = contact.profile.peerType {
+ "Bot"
+ } else {
+ "Your contact"
+ }
+ case let .group(groupInfo, _):
+ switch groupInfo.businessChat?.chatType {
+ case .none:
+ if groupInfo.nextConnectPrepared {
+ "Tap Join group"
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memInvited: "Join group"
+ case .memCreator: "Your group"
+ default: "Group"
+ }
+ }
+ case .business:
+ if groupInfo.nextConnectPrepared {
+ "Tap Connect to chat"
+ } else {
+ "Business connection"
+ }
+ case .customer:
+ "Your business contact"
+ }
+ default: nil
+ }
}
}
- class FloatingButtonModel: ObservableObject {
- static let shared = FloatingButtonModel()
- @Published var unreadBelow: Int = 0
- @Published var isNearBottom: Bool = true
- @Published var date: Date?
- @Published var isDateVisible: Bool = false
- var totalUnread: Int = 0
- var isReallyNearBottom: Bool = true
- var hideDateWorkItem: DispatchWorkItem?
+ private var connectingText: LocalizedStringKey? {
+ switch (chat.chatInfo) {
+ case let .direct(contact):
+ if !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest {
+ (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil
+ ? "contact should accept…"
+ : "connecting…"
+ } else {
+ nil
+ }
+ case let .group(groupInfo, _):
+ switch (groupInfo.membership.memberStatus) {
+ case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
+ case .memAccepted: "connecting…"
+ default: nil
+ }
+ default: nil
+ }
+ }
- func updateOnListChange(_ listState: ListState) {
- let im = ItemsModel.shared
- let unreadBelow =
- if let id = listState.bottomItemId,
- let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
- {
- im.reversedChatItems[.. 0 && listState.scrollOffset < 500
+ private func searchTextChanged(_ s: String) {
+ Task {
+ await loadChat(chat: chat, im: im, search: s)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
+ await MainActor.run {
+ scrollView.updateItems(mergedItems.boxedValue.items)
}
-
- // set floating button indication mode
- let nearBottom = listState.scrollOffset < 800
- 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)
+ 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()
}
}
-
- 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 {
+ @ObservedObject var im: ItemsModel
let theme: AppTheme
- let scrollModel: ReverseListScrollModel
+ let scrollView: EndlessScrollView
let chat: Chat
- @ObservedObject var model = FloatingButtonModel.shared
+ @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 {
+ if let date = model.date, date.timeIntervalSince1970 > 0 {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(model.isDateVisible ? 1 : 0)
+ .padding(.vertical, 4)
}
VStack {
- let unreadAbove = model.totalUnread - model.unreadBelow
- if unreadAbove > 0 {
- circleButton {
- unreadCountText(unreadAbove)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
- }
- .onTapGesture {
- scrollModel.scrollToNextPage()
- }
- .contextMenu {
- Button {
- Task {
- await markChatRead(chat)
+ 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(im, chat)
+ }
+ } label: {
+ Label("Mark read", systemImage: "checkmark")
}
- } label: {
- Label("Mark read", systemImage: "checkmark")
}
}
}
Spacer()
- if model.unreadBelow > 0 {
- circleButton {
- unreadCountText(model.unreadBelow)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
+ 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 || !im.lastItemsLoaded {
+ requestedTopScroll = false
+ requestedBottomScroll = true
+ } else {
+ scrollToBottom()
+ }
+ }
}
- .onTapGesture {
- scrollModel.scrollToBottom()
- }
- } else if !model.isNearBottom {
- circleButton {
- Image(systemName: "chevron.down")
- .foregroundColor(theme.colors.primary)
- }
- .onTapGesture { scrollModel.scrollToBottom() }
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
+ .onChange(of: loadingTopItems) { loading in
+ if !loading && requestedTopScroll {
+ requestedTopScroll = false
+ scrollToTopUnread()
+ }
+ }
+ .onChange(of: loadingBottomItems) { loading in
+ if !loading && requestedBottomScroll && im.lastItemsLoaded {
+ requestedBottomScroll = false
+ scrollToBottom()
+ }
+ }
.onDisappear(perform: model.resetDate)
}
+ private func scrollToTopUnread() {
+ Task {
+ if !im.chatState.splits.isEmpty {
+ await MainActor.run { loadingMoreItems = true }
+ await loadChat(chatId: chat.id, im: im, 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)")
+ }
+ }
+ }
+
+ 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()
@@ -677,14 +1256,32 @@ struct ChatView: View {
}
private func focusSearch() {
- searchMode = true
+ showSearch = true
searchFocussed = true
searchText = ""
}
+ private func closeSearch() {
+ showSearch = false
+ searchText = ""
+ searchFocussed = false
+ }
+
+ private func closeKeyboardAndRun(_ action: @escaping () -> Void) {
+ var delay: TimeInterval = 0
+ if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch {
+ delay = 0.5
+ closeSearch()
+ hideKeyboard()
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ action()
+ }
+ }
+
private func addMembersButton() -> some View {
Button {
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
@@ -694,11 +1291,12 @@ struct ChatView: View {
private func groupLinkButton() -> some View {
Button {
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
Task {
do {
- if let link = try apiGetGroupLink(gInfo.groupId) {
- (groupLink, groupLinkMemberRole) = link
+ if let gLink = try apiGetGroupLink(gInfo.groupId) {
+ groupLink = gLink
+ groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
@@ -745,6 +1343,7 @@ struct ChatView: View {
let (validItems, confirmation) = try await apiPlanForwardChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemIds: Array(selectedChatItems)
)
if let confirmation {
@@ -810,7 +1409,7 @@ struct ChatView: View {
)
}
}
-
+
func forwardAction(_ items: [Int64]) -> UIAlertAction {
UIAlertAction(
title: NSLocalizedString("Forward messages", comment: "alert action"),
@@ -834,7 +1433,6 @@ struct ChatView: View {
}
func openForwardingSheet(_ items: [Int64]) async {
- let im = ItemsModel.shared
var items = Set(items)
var fci = [ChatItem]()
for reversedChatItem in im.reversedChatItems {
@@ -848,43 +1446,38 @@ struct ChatView: View {
}
}
- private func loadChatItems(_ cInfo: ChatInfo) {
- Task {
- if loadingItems || firstPage { return }
- loadingItems = true
- do {
- var reversedPage = Array()
- var chatItemsAvailable = true
- // Load additional items until the page is +50 large after merging
- while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
- let pagination: ChatPagination =
- if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
- .before(chatItemId: lastItem.id, count: loadItemsPerPage)
- } else {
- .last(count: loadItemsPerPage)
- }
- let chatItems = try await apiGetChatItems(
- type: cInfo.chatType,
- id: cInfo.apiId,
- pagination: pagination,
- search: searchText
- )
- chatItemsAvailable = !chatItems.isEmpty
- reversedPage.append(contentsOf: chatItems.reversed())
- }
- await MainActor.run {
- if reversedPage.count == 0 {
- firstPage = true
- } else {
- im.reversedChatItems.append(contentsOf: reversedPage)
- }
- loadingItems = false
- }
- } catch let error {
- logger.error("apiGetChat error: \(responseError(error))")
- await MainActor.run { loadingItems = false }
+ 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,
+ im,
+ pagination,
+ searchText,
+ nil,
+ { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) }
+ )
+ return true
}
func stopAudioPlayer() {
@@ -892,96 +1485,151 @@ struct ChatView: View {
VoiceItemState.chatView = [:]
}
+ func onChatItemsUpdated() {
+ if !mergedItems.boxedValue.isActualState() {
+ //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)")
+ return
+ }
+ floatingButtonModel.updateOnListChange(scrollView.listState)
+ preloadIfNeeded(
+ im,
+ $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, im)
+ }
+ }
+ )
+ }
+
private struct ChatItemWithMenu: View {
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
@Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
+ let index: Int
+ let isLastItem: Bool
let chatItem: ChatItem
+ let scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
+ let merged: MergedItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@Binding var selectedMember: GMember?
@Binding var showChatInfoSheet: Bool
- @Binding var revealedChatItem: ChatItem?
+ @Binding var revealedItems: Set
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@State private var deletingItems: [Int64] = []
@State private var showDeleteMessages = false
+ @State private var archivingReports: Set? = nil
+ @State private var showArchivingReports = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@State private var msgWidth: CGFloat = 0
+ @State private var touchInProgress: Bool = false
@Binding var selectedChatItems: Set?
@Binding var forwardedChatItems: [ChatItem]
+ @Binding var searchText: String
+ var closeKeyboardAndRun: (@escaping () -> Void) -> Void
+
@State private var allowMenu: Bool = true
@State private var markedRead = false
+ @State private var markReadTask: Task? = nil
@State private var actionSheet: SomeActionSheet? = nil
- var revealed: Bool { chatItem == revealedChatItem }
+ var revealed: Bool { revealedItems.contains(chatItem.id) }
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
- func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
- let im = ItemsModel.shared
- if let i, i > 0 && im.reversedChatItems.count >= i {
- let nextItem = im.reversedChatItems[i - 1]
- let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
- return (
- timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
- largeGap: largeGap,
- date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
- )
+ 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 {
- return (timestamp: true, largeGap: true, date: nil)
+ chatItem.chatDir.sent == prevItem.chatDir.sent
+ }
+ let largeGap = !sameMemberAndDirection || prevItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
+
+ return ItemSeparation(
+ timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(prevItem.meta.itemTs),
+ largeGap: largeGap,
+ date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: prevItem.meta.itemTs) ? nil : prevItem.meta.itemTs
+ )
+ }
+
+ func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
+ let oldIsGroupRcv = switch older?.chatDir {
+ case .groupRcv: true
+ default: false
+ }
+ let sameMember = switch (older?.chatDir, current.chatDir) {
+ case (.groupRcv(let oldMember), .groupRcv(let member)):
+ oldMember.memberId == member.memberId
+ default:
+ false
+ }
+ if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
+ return true
+ } else {
+ return false
}
}
var body: some View {
- let currIndex = m.getChatItemIndex(chatItem)
- let ciCategory = chatItem.mergeCategory
- let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
- let range = itemsRange(currIndex, prevHidden)
- let timeSeparation = getItemSeparation(chatItem, at: currIndex)
- let im = ItemsModel.shared
- Group {
- if revealed, let range = range {
- let items = Array(zip(Array(range), im.reversedChatItems[range]))
- VStack(spacing: 0) {
- ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
- let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
- chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
- .overlay {
- if let selected = selectedChatItems, ci.canBeDeletedForSelf {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture {
- let checked = selected.contains(ci.id)
- selectUnselectChatItem(select: !checked, ci)
- }
- }
- }
- }
- }
- } else {
- VStack(spacing: 0) {
- chatItemView(chatItem, range, prevItem, timeSeparation)
- if let date = timeSeparation.date {
- DateSeparator(date: date).padding(8)
- }
- }
+ let last = isLastItem ? im.reversedChatItems.last : nil
+ let listItem = merged.newest()
+ let item = listItem.item
+ let range: ClosedRange? = if case let .grouped(_, _, _, rangeInReversed, _, _, _, _) = merged {
+ rangeInReversed.boxedValue
+ } else {
+ nil
+ }
+ let showAvatar = shouldShowAvatar(item, merged.oldest().nextItem)
+ let single = switch merged {
+ case .single: true
+ default: false
+ }
+ let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
+ return VStack(spacing: 0) {
+ if let last {
+ DateSeparator(date: last.meta.itemTs).padding(8)
+ }
+ chatItemListView(range, showAvatar, item, itemSeparation)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
- }
+ })
}
}
+ if let date = itemSeparation.date {
+ DateSeparator(date: date).padding(8)
}
}
.onAppear {
@@ -991,42 +1639,73 @@ struct ChatView: View {
markedRead = true
}
if let range {
- let itemIds = unreadItemIds(range)
+ let (itemIds, unreadMentions) = unreadItemIds(range)
if !itemIds.isEmpty {
waitToMarkRead {
- await apiMarkChatItemsRead(chat.chatInfo, itemIds)
+ await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions)
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
- await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
+ await apiMarkChatItemsRead(im, 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] {
- let im = ItemsModel.shared
- return range.compactMap { i in
- if i >= 0 && i < im.reversedChatItems.count {
- let ci = im.reversedChatItems[i]
- return if ci.isRcvNew { ci.id } else { nil }
- } else {
- return nil
+ private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) {
+ 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) {
- Task {
- _ = try? await Task.sleep(nanoseconds: 600_000000)
- if m.chatId == chat.chatInfo.id {
- await op()
+ markReadTask = Task {
+ do {
+ _ = try await Task.sleep(nanoseconds: 600_000000)
+ if m.chatId == chat.chatInfo.id {
+ await op()
+ }
+ } catch {
+ // task was cancelled
}
}
}
-
+
+ private var searchIsNotBlank: Bool {
+ get {
+ searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ }
@available(iOS 16.0, *)
struct MemberLayout: Layout {
@@ -1072,36 +1751,46 @@ struct ChatView: View {
}
}
- @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
+ @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 let .group(groupInfo, _) = chat.chatInfo {
+ if showAvatar {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Group {
- if memCount == 1 && member.memberRole > .member {
+ let (prevMember, memCount): (GroupMember?, Int) =
+ if let range = range {
+ m.getPrevHiddenMember(member, range)
+ } else {
+ (nil, 1)
+ }
+ if memCount == 1 && (member.memberRole > .member || ci.meta.showGroupAsSender) {
+ let (name, role) = if ci.meta.showGroupAsSender {
+ (groupInfo.chatViewName, NSLocalizedString("group", comment: "shown on group welcome message"))
+ } else {
+ (member.chatViewName, member.memberRole.text)
+ }
Group {
if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
- Text(member.chatViewName)
+ Text(name)
.lineLimit(1)
- Text(member.memberRole.text)
+ Text(role)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.trailing, 8)
}
} else {
HStack(spacing: 16) {
- Text(member.chatViewName)
+ Text(name)
.lineLimit(1)
- Text(member.memberRole.text)
+ Text(role)
.fontWeight(.semibold)
.lineLimit(1)
.layoutPriority(1)
@@ -1128,17 +1817,24 @@ struct ChatView: View {
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 10) {
- MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
- .onTapGesture {
- 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
- }
- }
+ if ci.meta.showGroupAsSender {
+ ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
+ .simultaneousGesture(TapGesture().onEnded {
+ showChatInfoSheet = true
+ })
+ } else {
+ 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 }
}
@@ -1188,20 +1884,31 @@ struct ChatView: View {
}
}
- @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
+ func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
- VStack(alignment: alignment.horizontal, spacing: 3) {
- ChatItemView(
- chat: chat,
- chatItem: ci,
- maxWidth: maxWidth,
- 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("")
+ return VStack(alignment: alignment.horizontal, spacing: 3) {
+ HStack {
+ if ci.chatDir.sent {
+ goToItemButton(true)
+ }
+ ChatItemView(
+ chat: chat,
+ im: im,
+ chatItem: ci,
+ scrollToItem: scrollToItem,
+ 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)
@@ -1222,12 +1929,28 @@ struct ChatView: View {
deleteMessages(chat, deletingItems, moderate: false)
}
}
+ .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) {
+ Button("For me", role: .destructive) {
+ if let reports = self.archivingReports {
+ archiveReports(chat, 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, reports.sorted(), true)
+ self.archivingReports = []
+ }
+ }
+ }
+ }
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
- ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
+ ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo)
}
}
@@ -1257,12 +1980,12 @@ struct ChatView: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
.if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
- v.onTapGesture {
+ v.simultaneousGesture(TapGesture().onEnded {
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
- }
+ })
}
switch chat.chatInfo {
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
v.contextMenu {
ReactionContextMenu(
groupInfo: groupInfo,
@@ -1285,7 +2008,7 @@ struct ChatView: View {
@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 case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator {
archiveReportButton(ci)
}
@@ -1343,9 +2066,13 @@ struct ChatView: View {
if ci.chatDir != .groupSnd {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
moderateButton(ci, groupInfo)
- } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording {
- // reportButton(ci)
- // }
+ } 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 {
@@ -1451,6 +2178,7 @@ struct ChatView: View {
let chatItem = try await apiChatItemReaction(
type: cInfo.chatType,
id: cInfo.apiId,
+ scope: cInfo.groupChatScope(),
itemId: ci.id,
add: add,
reaction: reaction
@@ -1510,7 +2238,7 @@ struct ChatView: View {
} label: {
Label(
NSLocalizedString("Save", comment: "chat item action"),
- systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open"
+ systemImage: "square.and.arrow.down"
)
}
}
@@ -1564,11 +2292,11 @@ struct ChatView: View {
Task {
do {
let cInfo = chat.chatInfo
- let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id)
+ let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id)
await MainActor.run {
chatItemInfo = ciInfo
}
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
await m.loadGroupMembers(gInfo)
}
} catch let error {
@@ -1609,7 +2337,7 @@ struct ChatView: View {
private func hideButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = nil
+ reveal(false)
}
} label: {
Label(
@@ -1622,13 +2350,13 @@ struct ChatView: View {
private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button {
Button(role: .destructive) {
if !revealed,
- let currIndex = m.getChatItemIndex(ci),
+ let currIndex = m.getChatItemIndex(im, 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(ItemsModel.shared.reversedChatItems[i].id)
+ itemIds.append(im.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -1677,20 +2405,11 @@ struct ChatView: View {
)
}
}
-
+
private func archiveReportButton(_ cItem: ChatItem) -> Button {
- Button(role: .destructive) {
- AlertManager.shared.showAlert(
- Alert(
- title: Text("Archive report?"),
- message: Text("The report will be archived for you."),
- primaryButton: .destructive(Text("Archive")) {
- deletingItem = cItem
- deleteMessage(.cidmInternalMark, moderate: false)
- },
- secondaryButton: .cancel()
- )
- )
+ Button {
+ archivingReports = [cItem.id]
+ showArchivingReports = true
} label: {
Label("Archive report", systemImage: "archivebox")
}
@@ -1699,7 +2418,7 @@ struct ChatView: View {
private func revealButton(_ ci: ChatItem) -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = ci
+ reveal(true)
}
} label: {
Label(
@@ -1712,7 +2431,7 @@ struct ChatView: View {
private func expandButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = chatItem
+ reveal(true)
}
} label: {
Label(
@@ -1725,7 +2444,7 @@ struct ChatView: View {
private func shrinkButton() -> Button {
Button {
withConditionalAnimation {
- revealedChatItem = nil
+ reveal(false)
}
} label: {
Label (
@@ -1734,7 +2453,7 @@ struct ChatView: View {
)
}
}
-
+
private func reportButton(_ ci: ChatItem) -> Button {
Button(role: .destructive) {
var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in
@@ -1748,9 +2467,9 @@ struct ChatView: View {
}
}
}
-
+
buttons.append(.cancel())
-
+
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Report reason?"),
@@ -1765,7 +2484,7 @@ struct ChatView: View {
)
}
}
-
+
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
@@ -1775,12 +2494,12 @@ struct ChatView: View {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
- let currIndex = m.getChatItemIndex(ci),
+ let currIndex = m.getChatItemIndex(im, 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)
+ itemIds.append(im.reversedChatItems[i].id)
}
} else {
itemIds.append(ci.id)
@@ -1814,6 +2533,7 @@ struct ChatView: View {
try await apiDeleteChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemIds: [di.id],
mode: mode
)
@@ -1830,6 +2550,7 @@ struct ChatView: View {
if deletedItem.isActiveReport {
m.decreaseGroupReportsCounter(chat.chatInfo.id)
}
+ m.updateChatInfo(itemDeletion.deletedChatItem.chatInfo)
}
}
}
@@ -1838,7 +2559,7 @@ struct ChatView: View {
}
}
}
-
+
@ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View {
if !r.userReacted || r.totalReacted > 1 {
Button { showChatInfoSheet = true } label: {
@@ -1853,6 +2574,34 @@ struct ChatView: View {
}
}
+ 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 {
+ im.loadOpenChatNoWait(chat.id, chatItem.id)
+ }
+ }
+ } else if let chatTypeApiIdMsgId {
+ goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
+ closeKeyboardAndRun {
+ let (chatType, apiId, msgId) = chatTypeApiIdMsgId
+ im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
+ }
+ }
+ }
+ }
+
private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme
var ciId: Int64
@@ -1874,6 +2623,84 @@ struct ChatView: View {
}
}
+class FloatingButtonModel: ObservableObject {
+ @ObservedObject var im: ItemsModel
+
+ public init(im: ItemsModel) {
+ self.im = im
+ }
+
+ @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, im.chatState.unreadTotal - lastVisibleItem.unreadBefore)
+ } else {
+ 0
+ }
+ let unreadAbove = im.chatState.unreadTotal - unreadBelow
+ let date: Date? =
+ if let lastVisible = listState.visibleItems.last {
+ Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
+ } else {
+ nil
+ }
+
+ // 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 func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
@@ -1895,6 +2722,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
+ scope: chatInfo.groupChatScope(),
itemIds: itemIds,
mode: mode
)
@@ -1903,15 +2731,18 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
await MainActor.run {
for di in deletedItems {
if let toItem = di.toChatItem {
- _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
+ _ = 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(chat.chatInfo.id)
+ ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id)
}
}
+ if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ }
}
await onSuccess()
} catch {
@@ -1921,11 +2752,46 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
}
}
+func archiveReports(_ chat: Chat, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) {
+ if itemIds.count > 0 {
+ let chatInfo = chat.chatInfo
+ 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)
+ }
+ }
+ if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ }
+ }
+ 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 {
+ } else if case let .group(groupInfo, _) = chat.chatInfo {
groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
} else {
nil as ThemeModeOverride?
@@ -1961,7 +2827,7 @@ struct ReactionContextMenu: View {
@ViewBuilder private func groupMemberReactionList() -> some View {
if memberReactions.isEmpty {
ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
- Text(verbatim: " ")
+ textSpace
}
} else {
ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in
@@ -2044,21 +2910,19 @@ struct ToggleNtfsButton: View {
@ObservedObject var chat: Chat
var body: some View {
- Button {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
- } label: {
- if chat.chatInfo.ntfsEnabled {
- Label("Mute", systemImage: "speaker.slash")
- } else {
- Label("Unmute", systemImage: "speaker.wave.2")
+ if let nextMode = chat.chatInfo.nextNtfMode {
+ Button {
+ toggleNotifications(chat, enableNtfs: nextMode)
+ } label: {
+ Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon)
}
}
}
}
-func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
+func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
- chatSettings.enableNtfs = enableNtfs ? .all : .none
+ chatSettings.enableNtfs = enableNtfs
updateChatSettings(chat, chatSettings: chatSettings)
}
@@ -2080,7 +2944,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
case var .direct(contact):
contact.chatSettings = chatSettings
ChatModel.shared.updateContact(contact)
- case var .group(groupInfo):
+ case var .group(groupInfo, _):
groupInfo.chatSettings = chatSettings
ChatModel.shared.updateGroup(groupInfo)
default: ()
@@ -2097,7 +2961,8 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
- ItemsModel.shared.reversedChatItems = [
+ let im = ItemsModel.shared
+ im.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -2109,7 +2974,13 @@ struct ChatView_Previews: PreviewProvider {
ChatItem.getSample(9, .directSnd, .now, "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.")
]
@State var showChatInfo = false
- return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
- .environmentObject(chatModel)
+ return ChatView(
+ chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: Binding.constant(nil)
+ )
+ .environmentObject(chatModel)
}
}
diff --git a/apps/ios/Shared/Views/Chat/CommandsMenuView.swift b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift
new file mode 100644
index 0000000000..525bf5725c
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift
@@ -0,0 +1,187 @@
+//
+// CommandsMenuView.swift
+// SimpleX (iOS)
+//
+// Created by EP on 03/08/2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let COMMAND_ROW_SIZE: CGFloat = 48
+let MAX_VISIBLE_COMMAND_ROWS: CGFloat = 5.8
+
+struct CommandsMenuView: View {
+ @EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @ObservedObject var chat: Chat
+ @Binding var composeState: ComposeState
+ @Binding var selectedRange: NSRange
+ @Binding var showCommandsMenu: Bool
+
+ @State private var currentCommands: [ChatBotCommand] = []
+ @State private var menuTreeBackPath: [(label: String, commands: [ChatBotCommand])] = []
+ @State private var keywordWidth: CGFloat = 0
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ if !currentCommands.isEmpty {
+ Color.white.opacity(0.01)
+ .edgesIgnoringSafeArea(.all)
+ .onTapGesture {
+ showCommandsMenu = false
+ currentCommands = []
+ menuTreeBackPath = []
+ }
+ VStack(spacing: 0) {
+ Spacer()
+ let cmdsCount = currentCommands.count + (menuTreeBackPath.isEmpty ? 0 : 1)
+ let scroll = ScrollView {
+ VStack(spacing: 0) {
+ if let prev = menuTreeBackPath.last {
+ Divider()
+ menuLabelRow(prev)
+ }
+ ForEach(currentCommands, id: \.self, content: commandRow)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: COMMAND_ROW_SIZE * min(MAX_VISIBLE_COMMAND_ROWS, CGFloat(cmdsCount)))
+ .background(theme.colors.background)
+
+ if #available(iOS 16.0, *) {
+ scroll.scrollDismissesKeyboard(.never)
+ } else {
+ scroll
+ }
+ }
+ .onPreferenceChange(DetermineWidth.Key.self) { keywordWidth = $0 }
+ }
+ }
+ .onChange(of: composeState.message) { message in
+ let msg = message.trimmingCharacters(in: .whitespaces)
+ if msg == "/" {
+ currentCommands = chat.chatInfo.menuCommands
+ } else if msg.first == "/" {
+ currentCommands = filterShownCommands(chat.chatInfo.menuCommands, msg.dropFirst())
+ } else {
+ showCommandsMenu = false
+ currentCommands = []
+ }
+ menuTreeBackPath = []
+ }
+ .onChange(of: showCommandsMenu) { show in
+ currentCommands = show ? chat.chatInfo.menuCommands : []
+ menuTreeBackPath = []
+ }
+ }
+
+ private func menuLabelRow(_ prev: (label: String, commands: [ChatBotCommand])) -> some View {
+ HStack {
+ Image(systemName: "chevron.left")
+ .foregroundColor(theme.colors.secondary)
+ Text(prev.label)
+ .fontWeight(.medium)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if !menuTreeBackPath.isEmpty {
+ currentCommands = menuTreeBackPath.removeLast().commands
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func commandRow(_ command: ChatBotCommand) -> some View {
+ Divider()
+ switch command {
+ case let .command(keyword, label, params):
+ HStack {
+ Text(label)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("/" + keyword)
+ .font(.subheadline)
+ .lineLimit(1)
+ .foregroundColor(theme.colors.secondary)
+ .frame(minWidth: keywordWidth, alignment: .trailing)
+ .overlay(DetermineWidth())
+ }
+ .padding(.horizontal)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if let params {
+ composeState.message = "/\(keyword) \(params)"
+ selectedRange = NSRange(location: composeState.message.count, length: 0)
+ } else {
+ composeState.message = ""
+ sendCommandMsg(chat, "/\(keyword)")
+ }
+ showCommandsMenu = false
+ currentCommands = []
+ menuTreeBackPath = []
+ }
+ case let .menu(label, cmds):
+ HStack {
+ Text(label)
+ .fontWeight(.medium)
+ .lineLimit(1)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .foregroundColor(theme.colors.secondary)
+ }
+ .padding(.horizontal)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ menuTreeBackPath.append((label: label, commands: currentCommands))
+ currentCommands = cmds
+ }
+ }
+ }
+
+ private func filterShownCommands(_ commands: [ChatBotCommand], _ msg: String.SubSequence) -> [ChatBotCommand] {
+ var cmds: [ChatBotCommand] = []
+ for command in commands {
+ switch command {
+ case let .command(keyword, _, _):
+ if keyword.starts(with: msg) {
+ cmds.append(command)
+ }
+ case let .menu(_, innerCmds):
+ cmds.append(contentsOf: filterShownCommands(innerCmds, msg))
+ }
+ }
+ return cmds
+ }
+}
+
+func sendCommandMsg(_ chat: Chat, _ cmd: String) {
+ if chat.chatInfo.sndReady {
+ Task {
+ if let chatItems = await apiSendMessages(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
+ composedMessages: [ComposedMessage(msgContent: .text(cmd))]
+ ) {
+ await MainActor.run {
+ for ci in chatItems {
+ ChatModel.shared.addChatItem(chat.chatInfo, ci)
+ }
+ }
+ }
+ }
+ } else {
+ showAlert(
+ NSLocalizedString("You can't send messages!", comment: "alert title"),
+ message: NSLocalizedString("To send commands you must be connected.", comment: "alert message"),
+ actions: { [okAlertAction] }
+ )
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
index 6c44aeea83..878ebd9cbf 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
@@ -18,7 +18,7 @@ struct ComposeLinkView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
- if let linkPreview = linkPreview {
+ if let linkPreview {
linkPreviewView(linkPreview)
} else {
ProgressView()
@@ -49,7 +49,7 @@ struct ComposeLinkView: View {
VStack(alignment: .center, spacing: 4) {
Text(linkPreview.title)
.lineLimit(1)
- Text(linkPreview.uri.absoluteString)
+ Text(linkPreview.uri)
.font(.caption)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
@@ -63,7 +63,7 @@ struct ComposeLinkView: View {
struct SmallLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(
- uri: URL(string: "http://DuckDuckGo.com")!,
+ uri: "http://DuckDuckGo.com",
title: "Privacy, simplified.",
description: "",
image: "data:image/jpg;base64,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"
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index a68a4987a1..3745d0f0b8 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -1,16 +1,11 @@
-//
-// ComposeView.swift
-// SimpleX
-//
-// Created by Evgeny on 13/03/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
import SwiftUI
import SimpleXChat
import SwiftyGif
import PhotosUI
+let MAX_NUMBER_OF_MENTIONS = 3
+
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
@@ -19,7 +14,7 @@ enum ComposePreview {
case filePreview(fileName: String, file: URL)
}
-enum ComposeContextItem {
+enum ComposeContextItem: Equatable {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
@@ -39,31 +34,42 @@ 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 progressByTimeout = false
+ var useLinkPreviews = true
+ 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,
@@ -72,10 +78,12 @@ 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
@@ -83,20 +91,38 @@ struct ComposeState {
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
@@ -117,14 +143,14 @@ struct ComposeState {
default: return false
}
}
-
+
var reporting: Bool {
switch contextItem {
case .reportedItem: return true
default: return false
}
}
-
+
var submittingValidReport: Bool {
switch contextItem {
case let .reportedItem(_, reason):
@@ -135,13 +161,13 @@ struct ComposeState {
default: return false
}
}
-
+
var sendEnabled: Bool {
switch preview {
case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
- default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
+ default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport
}
}
@@ -222,7 +248,11 @@ struct ComposeState {
}
var empty: Bool {
- message == "" && noPreview
+ whitespaceOnly && noPreview
+ }
+
+ var whitespaceOnly: Bool {
+ message.allSatisfy { $0.isWhitespace }
}
}
@@ -291,13 +321,18 @@ struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
@Binding var composeState: ComposeState
+ @Binding var showCommandsMenu: Bool
@Binding var keyboardVisible: Bool
+ @Binding var keyboardHiddenDate: Date
+ @Binding var selectedRange: NSRange
+ var disabledText: LocalizedStringKey? = nil
- @State var linkUrl: URL? = nil
+ @State var linkUrl: String? = nil
@State var hasSimplexLink: Bool = false
- @State var prevLinkUrl: URL? = nil
- @State var pendingLinkUrl: URL? = nil
+ @State var prevLinkUrl: String? = nil
+ @State var pendingLinkUrl: String? = nil
@State var cancelledLinks: Set = []
@Environment(\.colorScheme) private var colorScheme
@@ -317,23 +352,46 @@ struct ComposeView: View {
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+ @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
+ @State private var updatingCompose = false
var body: some View {
VStack(spacing: 0) {
Divider()
- if chat.chatInfo.contact?.nextSendGrpInv ?? false {
- ContextInvitingContactMemberView()
+
+ if chat.chatInfo.nextConnectPrepared,
+ let user = chatModel.currentUser {
+ ContextProfilePickerView(
+ chat: chat,
+ selectedUser: user
+ )
Divider()
}
-
+
+ if let groupInfo = chat.chatInfo.groupInfo,
+ case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
+ case let .memberSupport(member) = groupScopeInfo,
+ let member = member,
+ member.memberPending,
+ composeState.contextItem == .noContextItem,
+ composeState.noPreview {
+ ContextPendingMemberActionsView(
+ groupInfo: groupInfo,
+ member: member
+ )
+ 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 simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
+ let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
+ let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
@@ -350,76 +408,46 @@ struct ComposeView: View {
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
- HStack (alignment: .bottom) {
- let b = Button {
- showChooseSource = true
- } label: {
- Image(systemName: "paperclip")
- .resizable()
- }
- .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
- .frame(width: 25, height: 25)
- .padding(.bottom, 12)
- .padding(.leading, 12)
- .tint(theme.colors.primary)
- if case let .group(g) = chat.chatInfo,
- !g.fullGroupPreferences.files.on(for: g.membership) {
- b.disabled(true).onTapGesture {
- AlertManager.shared.showAlertMsg(
- title: "Files and media prohibited!",
- message: "Only group owners can enable files and media."
- )
- }
- } else {
- b
- }
- ZStack(alignment: .leading) {
- SendMessageView(
- composeState: $composeState,
- sendMessage: { ttl in
- sendMessage(ttl: ttl)
- resetLinkPreview()
- },
- sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
- updateLiveMessage: updateLiveMessage,
- cancelLiveMessage: {
- composeState.liveMessage = nil
- chatModel.removeLiveDummy()
- },
- nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
- voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
- disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
- showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
- startVoiceMessageRecording: {
- Task {
- await startVoiceMessageRecording()
- }
- },
- finishVoiceMessageRecording: finishVoiceMessageRecording,
- allowVoiceMessagesToContact: allowVoiceMessagesToContact,
- timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
- onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
- keyboardVisible: $keyboardVisible,
- sendButtonColor: chat.chatInfo.incognito
- ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
- : theme.colors.primary
- )
- .padding(.trailing, 12)
- .disabled(!chat.userCanSend)
- if chat.userIsObserver {
- Text("you are observer")
- .italic()
- .foregroundColor(theme.colors.secondary)
- .padding(.horizontal, 12)
- .onTapGesture {
- AlertManager.shared.showAlertMsg(
- title: "You can't send messages!",
- message: "Please contact group admin."
- )
- }
+ let contact = chat.chatInfo.contact
+
+ if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
+ if chat.chatInfo.groupInfo?.businessChat == nil {
+ connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup)
+ } else {
+ sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup)
+ }
+ } else if contact?.nextSendGrpInv == true {
+ contextSendMessageToConnect("Send direct message to connect")
+ Divider()
+ HStack (alignment: .center) {
+ attachmentAndCommandsButtons().disabled(true)
+ sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation)
+ }
+ .padding(.horizontal, 12)
+ } else if let contact,
+ contact.nextConnectPrepared == true,
+ let linkType = contact.preparedContact?.uiConnLinkType {
+ switch linkType {
+ case .inv:
+ connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact)
+ case .con:
+ if contact.isBot {
+ connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact)
+ } else {
+ sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest)
}
}
+ } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId {
+ ContextContactRequestActionsView(contactRequestId: crId)
+ } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv {
+ ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv)
+ } else {
+ HStack (alignment: .center) {
+ attachmentAndCommandsButtons()
+ sendMessageView(disableSendButton)
+ }
+ .padding(.horizontal, 12)
}
}
.background {
@@ -428,26 +456,58 @@ struct ComposeView: View {
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
- if composeState.linkPreviewAllowed {
- if msg.count > 0 {
- showLinkPreview(msg)
+ if updatingCompose {
+ updatingCompose = false
+ return
+ }
+ var parsedMsg = parseSimpleXMarkdown(msg)
+ if privacySanitizeLinks, let parsed = parsedMsg {
+ let r = sanitizeMessage(parsed)
+ if let sanitizedPos = r.sanitizedPos {
+ updatingCompose = true
+ composeState = composeState.copy(message: r.message, parsedMessage: r.parsedMsg)
+ if sanitizedPos < selectedRange.location {
+ selectedRange = NSRange(location: sanitizedPos, length: 0)
+ }
+ parsedMsg = r.parsedMsg
+ } else {
+ composeState = composeState.copy(parsedMessage: parsedMsg)
+ }
+ } else {
+ composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
+ }
+ if composeState.linkPreviewAllowed && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) {
+ if !msg.isEmpty {
+ showLinkPreview(parsedMsg)
} else {
resetLinkPreview()
hasSimplexLink = false
+ composeState = composeState.copy(preview: .noPreview)
}
- } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
- (_, hasSimplexLink) = parseMessage(msg)
} else {
- hasSimplexLink = false
+ resetLinkPreview()
+ hasSimplexLink = !msg.isEmpty && !chat.groupFeatureEnabled(.simplexLinks) && getMessageLinks(parsedMsg).hasSimplexLink
+ if composeState.linkPreviewAllowed {
+ composeState = composeState.copy(preview: .noPreview)
+ }
}
}
- .onChange(of: chat.userCanSend) { canSend in
- if !canSend {
+ .onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
+ if !sendEnabled {
cancelCurrentVoiceRecording()
clearCurrentDraft()
clearState()
}
}
+ .onChange(of: composeState.inProgress) { inProgress in
+ if inProgress {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ composeState.progressByTimeout = composeState.inProgress
+ }
+ } else {
+ composeState.progressByTimeout = false
+ }
+ }
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -583,6 +643,241 @@ struct ComposeView: View {
}
}
+ private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View {
+ Button(action: connect) {
+ ZStack(alignment: .trailing) {
+ Label(label, systemImage: icon)
+ .frame(maxWidth: .infinity)
+ if composeState.progressByTimeout {
+ ProgressView()
+ .padding()
+ }
+ }
+ }
+ .frame(height: 60)
+ .disabled(composeState.inProgress)
+ }
+
+ private func sendContactRequestView(_ disableSendButton: Bool, icon: String, sendRequest: @escaping () -> Void) -> some View {
+ HStack (alignment: .center) {
+ sendMessageView(
+ disableSendButton,
+ placeholder: NSLocalizedString("Add message", comment: "placeholder for sending contact request"),
+ sendToConnect: sendRequest
+ )
+ if composeState.whitespaceOnly {
+ Button(action: sendRequest) {
+ HStack {
+ Text("Connect").fontWeight(.medium)
+ Image(systemName: icon)
+ }
+ }
+ .padding(.horizontal, 8)
+ .disabled(composeState.inProgress)
+ }
+ }
+ .padding(.horizontal, 12)
+ }
+
+ private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View {
+ ZStack(alignment: .leading) {
+ SendMessageView(
+ placeholder: placeholder,
+ composeState: $composeState,
+ selectedRange: $selectedRange,
+ sendMessage: { ttl in
+ sendMessage(ttl: ttl)
+ resetLinkPreview()
+ },
+ sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
+ updateLiveMessage: updateLiveMessage,
+ cancelLiveMessage: {
+ composeState.liveMessage = nil
+ chatModel.removeLiveDummy()
+ },
+ sendToConnect: sendToConnect,
+ hideSendButton: chat.chatInfo.nextConnect && chat.chatInfo.contact?.nextSendGrpInv != true && composeState.whitespaceOnly,
+ voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
+ disableSendButton: disableSendButton,
+ showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
+ startVoiceMessageRecording: {
+ Task {
+ await startVoiceMessageRecording()
+ }
+ },
+ finishVoiceMessageRecording: finishVoiceMessageRecording,
+ allowVoiceMessagesToContact: allowVoiceMessagesToContact,
+ timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
+ onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
+ keyboardVisible: $keyboardVisible,
+ keyboardHiddenDate: $keyboardHiddenDate,
+ sendButtonColor: chat.chatInfo.incognito
+ ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
+ : theme.colors.primary
+ )
+ .disabled(!chat.chatInfo.sendMsgEnabled)
+
+ if let disabledText {
+ Text(disabledText)
+ .italic()
+ .foregroundColor(theme.colors.secondary)
+ .padding(.horizontal, 12)
+ }
+ }
+ }
+
+ @ViewBuilder private func attachmentAndCommandsButtons() -> some View {
+ let msg = composeState.message.trimmingCharacters(in: .whitespaces)
+ let showAttachment = chat.chatInfo.contact?.profile.peerType != .bot || chat.chatInfo.featureEnabled(.files)
+ let showCommands = chat.chatInfo.useCommands && (!showAttachment || msg.isEmpty || msg.starts(with: "/"))
+ if showCommands {
+ commandsButton()
+ }
+ if showAttachment {
+ attachmentButton()
+ .padding(.trailing, 3)
+ .if(showCommands) { v in v.padding(.leading, 3) }
+ }
+ }
+
+ private func commandsButton() -> some View {
+ Button {
+ showCommandsMenu.toggle()
+ } label: {
+ Text(verbatim: "//")
+ .font(.title3)
+ .italic()
+ .contentShape(Rectangle())
+ }
+ .disabled(!chat.chatInfo.sendMsgEnabled || chat.chatInfo.menuCommands.isEmpty)
+ .frame(width: 25, height: 25)
+ .tint(theme.colors.primary)
+ .padding(.bottom, 2)
+ }
+
+ @ViewBuilder private func attachmentButton() -> some View {
+ let b = Button {
+ showChooseSource = true
+ } label: {
+ Image(systemName: "paperclip")
+ .resizable()
+ }
+ .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled)
+ .frame(width: 25, height: 25)
+ .tint(theme.colors.primary)
+ if im.secondaryIMFilter == nil,
+ !chat.chatInfo.featureEnabled(.files) {
+ b.disabled(true).onTapGesture {
+ AlertManager.shared.showAlertMsg(
+ title: "Files and media prohibited!",
+ message: chat.chatInfo.groupInfo == nil ? nil : "Only group owners can enable files and media."
+ )
+ }
+ } else {
+ b
+ }
+ }
+
+ private func sendMemberContactInvitation() {
+ Task {
+ do {
+ await MainActor.run { hideKeyboard() }
+ if let mc = connectCheckLinkPreview() {
+ await sending()
+ let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
+ await MainActor.run {
+ self.chatModel.updateContact(contact)
+ clearState()
+ }
+ } else {
+ AlertManager.shared.showAlertMsg(title: "Empty message!")
+ }
+ } catch {
+ await MainActor.run { composeState.inProgress = false }
+ logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
+ AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
+ }
+ }
+ }
+
+ private func sendConnectPreparedContactRequest() {
+ hideKeyboard()
+ let empty = composeState.whitespaceOnly
+ AlertManager.shared.showAlert(Alert(
+ title: Text("Send contact request?"),
+ message: Text("You will be able to send messages **only after your request is accepted**."),
+ primaryButton: .default(
+ Text(empty ? "Send request without message" : "Send request"),
+ action: sendConnectPreparedContact
+ ),
+ secondaryButton:
+ empty
+ ? .cancel(Text("Add message"), action: hideKeyboard)
+ : .cancel()
+ ))
+ }
+
+ private func sendConnectPreparedContact() {
+ Task {
+ await MainActor.run { hideKeyboard() }
+ await sending()
+ let mc = connectCheckLinkPreview()
+ let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
+ if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
+ await MainActor.run {
+ self.chatModel.updateContact(contact)
+ clearState()
+ }
+ } else {
+ await MainActor.run { composeState.inProgress = false }
+ }
+ }
+ }
+
+ private func connectPreparedGroup() {
+ Task {
+ await MainActor.run { hideKeyboard() }
+ await sending()
+ let mc = connectCheckLinkPreview()
+ let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
+ if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
+ await MainActor.run {
+ self.chatModel.updateGroup(groupInfo)
+ clearState()
+ }
+ } else {
+ await MainActor.run { composeState.inProgress = false }
+ }
+ }
+ }
+
+ @inline(__always)
+ private func connectCheckLinkPreview() -> MsgContent? {
+ let msgText = composeState.message.trimmingCharacters(in: .whitespacesAndNewlines)
+ return msgText.isEmpty ? nil : checkLinkPreview_(msgText)
+ }
+
+ @inline(__always)
+ private func checkLinkPreview() -> MsgContent {
+ checkLinkPreview_(composeState.message.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+
+ private func checkLinkPreview_(_ msgText: String) -> MsgContent {
+ switch (composeState.preview) {
+ case let .linkPreview(linkPreview: linkPreview):
+ if let parsedMsg = parseSimpleXMarkdown(msgText),
+ let url = getMessageLinks(parsedMsg).url,
+ let linkPreview = linkPreview,
+ url == linkPreview.uri {
+ return .link(text: msgText, preview: linkPreview)
+ } else {
+ return .text(msgText)
+ }
+ default:
+ return .text(msgText)
+ }
+ }
+
private func addMediaContent(_ content: UploadContent) async {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
@@ -719,8 +1014,19 @@ struct ComposeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
-
-
+
+ private func contextSendMessageToConnect(_ s: LocalizedStringKey) -> some View {
+ HStack {
+ Image(systemName: "message")
+ .foregroundColor(theme.colors.secondary)
+ Text(s)
+ }
+ .padding(12)
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+
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")
@@ -793,17 +1099,16 @@ 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 {
+ 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)
+ _ = 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)
@@ -819,10 +1124,11 @@ 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)
+ 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 {
@@ -847,10 +1153,10 @@ struct ComposeView: View {
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)
}
}
}
@@ -878,23 +1184,6 @@ struct ComposeView: View {
nil
}
}
-
- func sending() async {
- await MainActor.run { composeState.inProgress = true }
- }
-
- func sendMemberContactInvitation() async {
- do {
- let mc = checkLinkPreview()
- let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
- await MainActor.run {
- self.chatModel.updateContact(contact)
- }
- } catch {
- logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
- AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
- }
- }
func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
if let oldMsgContent = ei.content.msgContent {
@@ -904,8 +1193,9 @@ struct ComposeView: View {
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemId: ei.id,
- msg: mc,
+ updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
live: live
)
await MainActor.run {
@@ -939,6 +1229,9 @@ struct ComposeView: View {
return .file(msgText)
case .report(_, let reason):
return .report(text: msgText, reason: reason)
+ // TODO [short links] update chat link
+ case let .chat(_, chatLink):
+ return .chat(text: msgText, chatLink: chatLink)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
@@ -958,7 +1251,7 @@ struct ComposeView: View {
return nil
}
}
-
+
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
if let chatItems = await apiReportMessage(
groupId: chat.chatInfo.apiId,
@@ -966,20 +1259,40 @@ struct ComposeView: View {
reportReason: reportReason,
reportText: msgText
) {
- await MainActor.run {
- for chatItem in chatItems {
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ if showReportsInSupportChatAlertDefault.get() {
+ await MainActor.run {
+ showReportsInSupportChatAlert()
}
}
return chatItems.first
}
-
+
return nil
}
-
- func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
+
+ func showReportsInSupportChatAlert() {
+ showAlert(
+ NSLocalizedString("Report sent to moderators", comment: "alert title"),
+ message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Don't show again", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ showReportsInSupportChatAlertDefault.set(false)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Ok", comment: "alert action"),
+ style: .default
+ )
+ ]}
+ )
+ }
+
+ 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)],
+ [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
live: live,
ttl: ttl
).first
@@ -991,6 +1304,7 @@ struct ComposeView: View {
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
live: live,
ttl: ttl,
composedMessages: msgs
@@ -1015,8 +1329,10 @@ struct ComposeView: View {
if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
+ toScope: chat.chatInfo.groupChatScope(),
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
+ fromScope: fromChatInfo.groupChatScope(),
itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
@@ -1039,21 +1355,10 @@ struct ComposeView: View {
return []
}
}
+ }
- func checkLinkPreview() -> MsgContent {
- switch (composeState.preview) {
- case let .linkPreview(linkPreview: linkPreview):
- if let url = parseMessage(msgText).url,
- let linkPreview = linkPreview,
- url == linkPreview.uri {
- return .link(text: msgText, preview: linkPreview)
- } else {
- return .text(msgText)
- }
- default:
- return .text(msgText)
- }
- }
+ func sending() async {
+ await MainActor.run { composeState.inProgress = true }
}
private func startVoiceMessageRecording() async {
@@ -1162,9 +1467,9 @@ struct ComposeView: View {
}
}
- private func showLinkPreview(_ s: String) {
+ private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
- (linkUrl, hasSimplexLink) = parseMessage(s)
+ (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -1181,43 +1486,45 @@ struct ComposeView: View {
}
}
- private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
- guard let parsedMsg = parseSimpleXMarkdown(msg) 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)
- }) {
- URL(string: uri.text)
- } else {
- nil
- }
+ private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) {
+ guard let parsedMsg else { return (nil, false) }
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
- return (url, simplexLink)
+ for ft in parsedMsg {
+ if let link = ft.linkUri, !cancelledLinks.contains(link) && !isSimplexLink(link) {
+ return (link, simplexLink)
+ }
+ }
+ return (nil, simplexLink)
}
private func isSimplexLink(_ link: String) -> Bool {
- link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
+ link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") || link.starts(with: "simplex:/")
}
private func cancelLinkPreview() {
- if let pendingLink = pendingLinkUrl?.absoluteString {
+ if let pendingLink = pendingLinkUrl {
cancelledLinks.insert(pendingLink)
}
- if let uri = composeState.linkPreview?.uri.absoluteString {
+ if let uri = composeState.linkPreview?.uri {
cancelledLinks.insert(uri)
}
pendingLinkUrl = nil
composeState = composeState.copy(preview: .noPreview)
}
- private func loadLinkPreview(_ url: URL) {
- if pendingLinkUrl == url {
+ private func loadLinkPreview(_ urlStr: String) {
+ if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in
- if let linkPreview = linkPreview,
- pendingLinkUrl == url {
+ if let linkPreview, pendingLinkUrl == urlStr {
+ privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
- pendingLinkUrl = nil
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ composeState = composeState.copy(preview: .noPreview)
+ }
}
+ pendingLinkUrl = nil
}
}
}
@@ -1230,24 +1537,31 @@ struct ComposeView: View {
}
}
-struct ComposeView_Previews: PreviewProvider {
- static var previews: some View {
- let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
- @State var composeState = ComposeState(message: "hello")
-
- return Group {
- ComposeView(
- chat: chat,
- composeState: $composeState,
- keyboardVisible: Binding.constant(true)
- )
- .environmentObject(ChatModel())
- ComposeView(
- chat: chat,
- composeState: $composeState,
- keyboardVisible: Binding.constant(true)
- )
- .environmentObject(ChatModel())
+func sanitizeMessage(_ parsedMsg: [FormattedText]) -> (message: String, parsedMsg: [FormattedText], sanitizedPos: Int?) {
+ var pos: Int = 0
+ var updatedMsg = ""
+ var sanitizedPos: Int? = nil
+ let updatedParsedMsg = parsedMsg.map { ft in
+ var updated = ft
+ switch ft.format {
+ case .uri:
+ if let sanitized = parseSanitizeUri(ft.text, safe: true)?.uriInfo?.sanitized {
+ updated = FormattedText(text: sanitized, format: .uri)
+ pos += updated.text.count
+ sanitizedPos = pos
+ }
+ case let .hyperLink(text, uri):
+ if let sanitized = parseSanitizeUri(uri, safe: true)?.uriInfo?.sanitized {
+ let updatedText = if let text { "[\(text)](\(sanitized))" } else { sanitized }
+ updated = FormattedText(text: updatedText, format: .hyperLink(showText: text, linkUri: sanitized))
+ pos += updated.text.count
+ sanitizedPos = pos
+ }
+ default:
+ pos += ft.text.count
}
+ updatedMsg += updated.text
+ return updated
}
+ return (message: updatedMsg, parsedMsg: updatedParsedMsg, sanitizedPos: sanitizedPos)
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift
new file mode 100644
index 0000000000..82c89cd43d
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift
@@ -0,0 +1,97 @@
+//
+// ContextContactRequestActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 02.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextContactRequestActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ var contactRequestId: Int64
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @State private var inProgress = false
+ @State private var progressByTimeout = false
+
+ var body: some View {
+ HStack(spacing: 0) {
+ Button(role: .destructive, action: showRejectRequestAlert) {
+ Label("Reject", systemImage: "multiply")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+
+ Button {
+ if ChatModel.shared.addressShortLinkDataSet {
+ acceptRequest()
+ } else {
+ showAcceptRequestAlert()
+ }
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+ }
+ .disabled(inProgress)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ .opacity(progressByTimeout ? 0.4 : 1)
+ .overlay {
+ if progressByTimeout {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onChange(of: inProgress) { inPrgrs in
+ if inPrgrs {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ progressByTimeout = inProgress
+ }
+ } else {
+ progressByTimeout = false
+ }
+ }
+ }
+
+ private func showRejectRequestAlert() {
+ showAlert(
+ NSLocalizedString("Reject contact request", comment: "alert title"),
+ message: NSLocalizedString("The sender will NOT be notified", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Reject", comment: "alert action"), style: .destructive) { _ in
+ Task { await rejectContactRequest(contactRequestId, dismissToChatList: true) }
+ },
+ cancelAlertAction
+ ]}
+ )
+ }
+
+ private func showAcceptRequestAlert() {
+ showAlert(
+ NSLocalizedString("Accept contact request", comment: "alert title"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Accept", comment: "alert action"), style: .default) { _ in
+ acceptRequest()
+ },
+ UIAlertAction(title: NSLocalizedString("Accept incognito", comment: "alert action"), style: .default) { _ in
+ acceptRequest(incognito: true)
+ },
+ cancelAlertAction
+ ]}
+ )
+ }
+
+ private func acceptRequest(incognito: Bool = false) {
+ Task {
+ await acceptContactRequest(incognito: incognito, contactRequestId: contactRequestId, inProgress: $inProgress)
+ }
+ }
+}
+
+#Preview {
+ ContextContactRequestActionsView(
+ contactRequestId: 1
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
deleted file mode 100644
index 82090f312a..0000000000
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-//
-// ContextInvitingContactMemberView.swift
-// SimpleX (iOS)
-//
-// Created by spaced4ndy on 18.09.2023.
-// Copyright © 2023 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-
-struct ContextInvitingContactMemberView: View {
- @EnvironmentObject var theme: AppTheme
-
- var body: some View {
- HStack {
- Image(systemName: "message")
- .foregroundColor(theme.colors.secondary)
- Text("Send direct message to connect")
- }
- .padding(12)
- .frame(minHeight: 54)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.thinMaterial)
- }
-}
-
-struct ContextInvitingContactMemberView_Previews: PreviewProvider {
- static var previews: some View {
- ContextInvitingContactMemberView()
- }
-}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index 3cb747ec68..845442c75f 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -70,8 +70,10 @@ struct ContextItemView: View {
.lineLimit(lines)
}
- private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
- return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
+ let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background))
+ let t = attachment() + Text(AttributedString(r.string))
+ return t.if(r.hasSecrets, transform: hiddenSecretsView)
func attachment() -> Text {
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift
new file mode 100644
index 0000000000..9a73b2b5d4
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift
@@ -0,0 +1,110 @@
+//
+// ContextMemberContactActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 31.07.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextMemberContactActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ var contact: Contact
+ var groupDirectInv: GroupDirectInvitation
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @State private var inProgress = false
+ @State private var progressByTimeout = false
+
+ var body: some View {
+ VStack {
+ if groupDirectInv.memberRemoved {
+ Label("Member is deleted - can't accept request", systemImage: "info.circle")
+ .foregroundColor(theme.colors.secondary)
+ .font(.subheadline)
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity, minHeight: 60)
+ } else {
+ HStack(spacing: 0) {
+ Button(role: .destructive, action: { showRejectMemberContactRequestAlert(contact) }) {
+ Label("Reject", systemImage: "multiply")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+
+ Button {
+ acceptMemberContactRequest(contact, inProgress: $inProgress)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+ }
+ }
+ }
+ .disabled(inProgress || groupDirectInv.memberRemoved)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ .opacity(progressByTimeout ? 0.4 : 1)
+ .overlay {
+ if progressByTimeout {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onChange(of: inProgress) { inPrgrs in
+ if inPrgrs {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ progressByTimeout = inProgress
+ }
+ } else {
+ progressByTimeout = false
+ }
+ }
+ }
+}
+
+func showRejectMemberContactRequestAlert(_ contact: Contact) {
+ showAlert(
+ NSLocalizedString("Reject contact request", comment: "alert title"),
+ message: NSLocalizedString("The sender will NOT be notified", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Reject", comment: "alert action"), style: .destructive) { _ in
+ deleteContact(contact)
+ },
+ cancelAlertAction
+ ]}
+ )
+}
+
+private func deleteContact(_ contact: Contact) {
+ Task {
+ do {
+ _ = try await apiDeleteContact(id: contact.contactId, chatDeleteMode: .full(notify: false))
+ await MainActor.run {
+ ChatModel.shared.removeChat(contact.id)
+ ChatModel.shared.chatId = nil
+ }
+ } catch let error {
+ logger.error("apiDeleteContact: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error deleting chat!", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+func acceptMemberContactRequest(_ contact: Contact, inProgress: Binding? = nil) {
+ Task {
+ await acceptMemberContact(contactId: contact.contactId, inProgress: inProgress)
+ }
+}
+
+#Preview {
+ ContextMemberContactActionsView(
+ contact: Contact.sampleData,
+ groupDirectInv: GroupDirectInvitation.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift
new file mode 100644
index 0000000000..e9913053ea
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift
@@ -0,0 +1,106 @@
+//
+// ContextPendingMemberActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 02.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextPendingMemberActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+ var groupInfo: GroupInfo
+ var member: GroupMember
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ZStack {
+ Text("Reject")
+ .foregroundColor(.red)
+ }
+ .frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showRejectMemberAlert(groupInfo, member, dismiss: dismiss)
+ }
+
+ ZStack {
+ Text("Accept")
+ .foregroundColor(theme.colors.primary)
+ }
+ .frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showAcceptMemberAlert(groupInfo, member, dismiss: dismiss)
+ }
+ }
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+}
+
+func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
+ showAlert(
+ title: NSLocalizedString("Reject member?", comment: "alert title"),
+ buttonTitle: "Reject",
+ buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) },
+ cancelButton: true
+ )
+}
+
+func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
+ showAlert(
+ NSLocalizedString("Accept member", comment: "alert title"),
+ message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Accept as member", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ acceptMember(groupInfo, member, .member, dismiss: dismiss)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Accept as observer", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ acceptMember(groupInfo, member, .observer, dismiss: dismiss)
+ }
+ ),
+ cancelAlertAction
+ ]}
+ )
+}
+
+func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) {
+ Task {
+ do {
+ let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role)
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember)
+ ChatModel.shared.updateGroup(gInfo)
+ dismiss?()
+ }
+ } catch let error {
+ logger.error("apiAcceptMember error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting member", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ ContextPendingMemberActionsView(
+ groupInfo: GroupInfo.sampleData,
+ member: GroupMember.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
new file mode 100644
index 0000000000..427a600627
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
@@ -0,0 +1,305 @@
+//
+// ContextProfilePickerView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 13.06.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let USER_ROW_SIZE: CGFloat = 60
+let MAX_VISIBLE_USER_ROWS: CGFloat = 4.8
+
+struct ContextProfilePickerView: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State var selectedUser: User
+ @State private var users: [User] = []
+ @State private var listExpanded = false
+ @State private var expandedListReady = false
+ @State private var showIncognitoSheet = false
+
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+
+ var body: some View {
+ viewBody()
+ .onAppear {
+ users = chatModel.users
+ .map { $0.user }
+ .filter { u in u.activeUser || !u.hidden }
+ }
+ .sheet(isPresented: $showIncognitoSheet) {
+ IncognitoHelp()
+ }
+ }
+
+ private func viewBody() -> some View {
+ Group {
+ if !listExpanded || chat.chatInfo.profileChangeProhibited {
+ currentSelection()
+ } else {
+ profilePicker()
+ }
+ }
+ }
+
+ private func currentSelection() -> some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Your profile")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ Spacer()
+ }
+ .padding(.top, 8)
+ .padding(.bottom, -4)
+ .padding(.leading, 12)
+ .padding(.trailing)
+
+ if chat.chatInfo.profileChangeProhibited {
+ if chat.chatInfo.incognito {
+ incognitoOption()
+ } else {
+ profilerPickerUserOption(selectedUser)
+ }
+ } else if incognitoDefault {
+ incognitoOption()
+ } else {
+ profilerPickerUserOption(selectedUser)
+ }
+ }
+ }
+
+ private func profilePicker() -> some View {
+ ScrollViewReader { proxy in
+ Group {
+ if expandedListReady {
+ let scroll = ScrollView {
+ LazyVStack(spacing: 0) {
+ let otherUsers = users
+ .filter { u in u.userId != selectedUser.userId }
+ .sorted(using: KeyPathComparator(\.activeOrder))
+ ForEach(otherUsers) { p in
+ profilerPickerUserOption(p)
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+ }
+
+ if incognitoDefault {
+ profilerPickerUserOption(selectedUser)
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+
+ incognitoOption()
+ .contentShape(Rectangle())
+ .id("BOTTOM_ANCHOR")
+ } else {
+ incognitoOption()
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+
+ profilerPickerUserOption(selectedUser)
+ .contentShape(Rectangle())
+ .id("BOTTOM_ANCHOR")
+ }
+ }
+ }
+ .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 1))) // + 1 for incognito
+ .onAppear {
+ DispatchQueue.main.async {
+ withAnimation(nil) {
+ proxy.scrollTo("BOTTOM_ANCHOR", anchor: .bottom)
+ }
+ }
+ }
+ .onDisappear {
+ expandedListReady = false
+ }
+
+ if #available(iOS 16.0, *) {
+ scroll.scrollDismissesKeyboard(.never)
+ } else {
+ scroll
+ }
+ } else {
+ // Keep showing current selection to avoid flickering of scroll to bottom
+ currentSelection()
+ .onAppear {
+ // Delay rendering of expanded profile list
+ DispatchQueue.main.async {
+ expandedListReady = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func profilerPickerUserOption(_ user: User) -> some View {
+ Button {
+ if !chat.chatInfo.profileChangeProhibited {
+ if selectedUser == user {
+ if !incognitoDefault {
+ listExpanded.toggle()
+ } else {
+ incognitoDefault = false
+ listExpanded = false
+ }
+ } else if selectedUser != user {
+ changeProfile(user)
+ }
+ } else {
+ showCantChangeProfileAlert()
+ }
+ } label: {
+ HStack {
+ ProfileImage(imageStr: user.image, size: 38)
+ Text(user.chatViewName)
+ .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+
+ Spacer()
+
+ if selectedUser == user && !incognitoDefault {
+ if listExpanded {
+ Image(systemName: "chevron.down")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ } else if !chat.chatInfo.profileChangeProhibited {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ }
+ }
+ }
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .frame(height: USER_ROW_SIZE)
+ }
+ }
+
+ private func changeProfile(_ newUser: User) {
+ Task {
+ do {
+ if let contact = chat.chatInfo.contact {
+ let updatedContact = try await apiChangePreparedContactUser(contactId: contact.contactId, newUserId: newUser.userId)
+ await MainActor.run {
+ selectedUser = newUser
+ incognitoDefault = false
+ listExpanded = false
+ chatModel.updateContact(updatedContact)
+ }
+ } else if let groupInfo = chat.chatInfo.groupInfo {
+ let updatedGroupInfo = try await apiChangePreparedGroupUser(groupId: groupInfo.groupId, newUserId: newUser.userId)
+ await MainActor.run {
+ selectedUser = newUser
+ incognitoDefault = false
+ listExpanded = false
+ chatModel.updateGroup(updatedGroupInfo)
+ }
+ }
+ do {
+ try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id)
+ } catch {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error switching profile", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile.", comment: "alert message"), newUser.chatViewName)
+ )
+ }
+ }
+ } catch let error {
+ await MainActor.run {
+ if let currentUser = chatModel.currentUser {
+ selectedUser = currentUser
+ }
+ showAlert(
+ NSLocalizedString("Error changing chat profile", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ }
+
+ private func incognitoOption() -> some View {
+ Button {
+ if !chat.chatInfo.profileChangeProhibited {
+ if incognitoDefault {
+ listExpanded.toggle()
+ } else {
+ incognitoDefault = true
+ listExpanded = false
+ }
+ } else {
+ showCantChangeProfileAlert()
+ }
+ } label : {
+ HStack {
+ incognitoProfileImage()
+ Text("Incognito")
+ .fontWeight(incognitoDefault ? .medium : .regular)
+ .foregroundColor(theme.colors.onBackground)
+ Image(systemName: "info.circle")
+ .font(.system(size: 16))
+ .foregroundColor(theme.colors.primary)
+ .onTapGesture {
+ showIncognitoSheet = true
+ }
+
+ Spacer()
+
+ if incognitoDefault {
+ if listExpanded {
+ Image(systemName: "chevron.down")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ } else if !chat.chatInfo.profileChangeProhibited {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ }
+ }
+ }
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .frame(height: USER_ROW_SIZE)
+ }
+ }
+
+ private func incognitoProfileImage() -> some View {
+ Image(systemName: "theatermasks.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 38)
+ .foregroundColor(.indigo)
+ }
+
+ private func showCantChangeProfileAlert() {
+ showAlert(
+ NSLocalizedString("Can't change profile", comment: "alert title"),
+ message: NSLocalizedString("To use another profile after connection attempt, delete the chat and use the link again.", comment: "alert message")
+ )
+ }
+}
+
+#Preview {
+ ContextProfilePickerView(
+ chat: Chat.sampleData,
+ selectedUser: User.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
index 2fc122f249..c5fd8e39d0 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
@@ -16,19 +16,15 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool
@Binding var height: CGFloat
@Binding var focused: Bool
+ @Binding var lastUnfocusedDate: Date
@Binding var placeholder: String?
+ @Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void
- private let minHeight: CGFloat = 37
+ static let minHeight: CGFloat = 39
- private let defaultHeight: CGFloat = {
- let field = CustomUITextField(height: Binding.constant(0))
- field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
- return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
- }()
-
- func makeUIView(context: Context) -> UITextView {
- let field = CustomUITextField(height: _height)
+ func makeUIView(context: Context) -> CustomUITextField {
+ let field = CustomUITextField(parent: self, height: _height)
field.backgroundColor = .clear
field.text = text
field.textAlignment = alignment(text)
@@ -37,10 +33,9 @@ struct NativeTextEditor: UIViewRepresentable {
if !disableEditing {
text = newText
field.textAlignment = alignment(text)
- updateFont(field)
+ field.updateFont()
// Speed up the process of updating layout, reduce jumping content on screen
- updateHeight(field)
- self.height = field.frame.size.height
+ field.updateHeight()
} else {
field.text = text
}
@@ -48,49 +43,45 @@ 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)
field.setPlaceholderView()
- updateFont(field)
- updateHeight(field)
+ field.updateFont()
+ field.updateHeight(updateBindingNow: false)
return field
}
- func updateUIView(_ field: UITextView, context: Context) {
+ func updateUIView(_ field: CustomUITextField, context: Context) {
if field.markedTextRange == nil && field.text != text {
field.text = text
field.textAlignment = alignment(text)
- updateFont(field)
- updateHeight(field)
+ field.updateFont()
+ field.updateHeight(updateBindingNow: false)
+ field.placeholder = text.isEmpty ? placeholder : ""
}
-
- let castedField = field as! CustomUITextField
- if castedField.placeholder != placeholder {
- castedField.placeholder = placeholder
+ if field.placeholder != placeholder {
+ field.placeholder = text.isEmpty ? placeholder : ""
}
- }
-
- 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)
- }
- }
-
- private func updateFont(_ field: UITextView) {
- let newFont = isShortEmoji(field.text)
- ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
- : UIFont.preferredFont(forTextStyle: .body)
- if field.font != newFont {
- field.font = newFont
+ if field.selectedRange != selectedRange {
+ field.selectedRange = selectedRange
}
+// This block causes delays in closing keyboard when navigating from chat view to chat list.
+// It is also a candidate for iOS 26.1 freeze.
+// This was added in commit below to open keyboard programmatically via a passed binding but this approach is not reliable.
+// https://github.com/simplex-chat/simplex-chat/pull/6003/commits/cb666de51375623451a5e80dcf59449adc7d2a5f
+// if focused && !field.isFocused {
+// DispatchQueue.main.async {
+// if !field.isFocused {
+// field.becomeFirstResponder()
+// }
+// }
+// }
}
}
@@ -98,15 +89,17 @@ private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left
}
-private class CustomUITextField: UITextView, UITextViewDelegate {
+class CustomUITextField: UITextView, UITextViewDelegate {
+ var parent: NativeTextEditor?
var height: Binding
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
-
+
private let placeholderLabel: UILabel = UILabel()
- init(height: Binding) {
+ init(parent: NativeTextEditor?, height: Binding) {
+ self.parent = parent
self.height = height
super.init(frame: .zero, textContainer: nil)
}
@@ -128,11 +121,44 @@ 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) {
@@ -232,10 +258,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
+ }
}
}
@@ -246,7 +284,9 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false),
height: Binding.constant(100),
focused: Binding.constant(false),
+ lastUnfocusedDate: Binding.constant(.now),
placeholder: Binding.constant("Placeholder"),
+ selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
onImagesAdded: { _ in }
)
.fixedSize(horizontal: false, vertical: true)
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index fb69dfdd17..07cd61583b 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -12,13 +12,17 @@ import SimpleXChat
private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
+ var placeholder: String?
@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
var cancelLiveMessage: (() -> Void)? = nil
- var nextSendGrpInv: Bool = false
+ var sendToConnect: (() -> Void)? = nil
+ var hideSendButton: Bool = false
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
var disableSendButton = false
@@ -31,81 +35,76 @@ 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
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
- @State private var progressByTimeout = false
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
- ZStack {
- let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
- HStack(alignment: .bottom) {
- ZStack(alignment: .leading) {
- if case .voicePreview = composeState.preview {
- Text("Voice message…")
- .font(teFont.italic())
- .multilineTextAlignment(.leading)
- .foregroundColor(theme.colors.secondary)
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity)
- } else {
- NativeTextEditor(
- text: $composeState.message,
- disableEditing: $composeState.inProgress,
- height: $teHeight,
- focused: $keyboardVisible,
- placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
- 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)
- }
- }
- .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 {
- DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
- progressByTimeout = composeState.inProgress
- }
+ 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 {
- progressByTimeout = false
+ NativeTextEditor(
+ text: $composeState.message,
+ disableEditing: $composeState.inProgress,
+ height: $teHeight,
+ focused: $keyboardVisible,
+ lastUnfocusedDate: $keyboardHiddenDate,
+ placeholder: Binding(get: { placeholder ?? composeState.placeholder }, set: { _ in }),
+ selectedRange: $selectedRange,
+ onImagesAdded: onMediaAdded
+ )
+ .padding(.trailing, 32)
+ .allowsTightening(false)
+ .fixedSize(horizontal: false, vertical: true)
}
}
+ .overlay(alignment: .topTrailing, content: {
+ if !composeState.progressByTimeout && teHeight > 100 && !composeState.inProgress {
+ deleteTextButton()
+ }
+ })
+ .overlay(alignment: .bottomTrailing) {
+ if composeState.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) })
.padding(.vertical, 8)
}
@ViewBuilder private func composeActionButtons() -> some View {
let vmrs = composeState.voiceMessageRecordingState
- if nextSendGrpInv {
- inviteMemberContactButton()
+ if hideSendButton {
+ EmptyView()
+ } else if let connect = sendToConnect {
+ sendToConnectButton(connect)
} else if case .reportedItem = composeState.contextItem {
sendMessageButton()
} else if showVoiceMessageButton
@@ -153,21 +152,17 @@ struct SendMessageView: View {
.padding([.top, .trailing], 4)
}
- private func inviteMemberContactButton() -> some View {
- Button {
- sendMessage(nil)
- } label: {
+ private func sendToConnectButton(_ connect: @escaping () -> Void) -> some View {
+ let disabled = !composeState.sendEnabled || composeState.inProgress || disableSendButton
+ return Button(action: connect) {
Image(systemName: "arrow.up.circle.fill")
.resizable()
- .foregroundColor(sendButtonColor)
+ .foregroundColor(disabled ? theme.colors.secondary.opacity(0.67) : sendButtonColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
- .disabled(
- !composeState.sendEnabled ||
- composeState.inProgress
- )
- .frame(width: 29, height: 29)
+ .disabled(disabled)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -190,7 +185,7 @@ struct SendMessageView: View {
composeState.endLiveDisabled ||
disableSendButton
)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.contextMenu{
sendButtonContextMenuItems()
}
@@ -251,6 +246,7 @@ struct SendMessageView: View {
}
private struct RecordVoiceMessageButton: View {
+ @Environment(\.isEnabled) var isEnabled
@EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@@ -259,15 +255,14 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil
var body: some View {
- Button(action: {}) {
- Image(systemName: "mic.fill")
- .resizable()
- .scaledToFit()
- .frame(width: 20, height: 20)
- .foregroundColor(theme.colors.primary)
- }
+ Image(systemName: isEnabled ? "mic.fill" : "mic")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
+ .opacity(holdingVMR ? 0.7 : 1)
.disabled(disabled)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
._onButtonGesture { down in
if down {
@@ -275,9 +270,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
@@ -323,7 +316,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -351,7 +344,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
- .foregroundColor(theme.colors.primary)
+ .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)
@@ -408,7 +401,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.primary)
}
.disabled(composeState.inProgress)
- .frame(width: 29, height: 29)
+ .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -424,8 +417,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 {
@@ -434,9 +429,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 {
@@ -444,9 +441,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/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 66fe67a29e..3154f16f5b 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -78,6 +78,12 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count
Section {
if creatingGroup {
+ MemberAdmissionButton(
+ groupInfo: $groupInfo,
+ admission: groupInfo.groupProfile.memberAdmission_,
+ currentAdmission: groupInfo.groupProfile.memberAdmission_,
+ creatingGroup: true
+ )
GroupPreferencesButton(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
@@ -145,9 +151,9 @@ struct AddGroupMembersViewCommon: View {
return dummy
}()
- @ViewBuilder private func inviteMembersButton() -> some View {
+ private func inviteMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
- Button {
+ return Button {
inviteMembers()
} label: {
HStack {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index b0f896e493..96b5e2898a 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -17,11 +17,12 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
+ @Binding var scrollToItemId: ChatItem.ID?
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: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@@ -33,6 +34,7 @@ struct GroupChatInfoView: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
+ @State private var showSecrets: Set = []
enum GroupChatInfoViewAlert: Identifiable {
case deleteGroupAlert
@@ -44,7 +46,6 @@ struct GroupChatInfoView: View {
case unblockMemberAlert(mem: GroupMember)
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
- case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
@@ -58,7 +59,6 @@ struct GroupChatInfoView: View {
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
- case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)"
}
}
@@ -74,12 +74,12 @@ struct GroupChatInfoView: View {
List {
groupInfoHeader()
.listRowBackground(Color.clear)
-
+
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
-
+
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
@@ -87,7 +87,25 @@ struct GroupChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
-
+
+ Section {
+ if groupInfo.canAddMembers && groupInfo.businessChat == nil {
+ groupLinkButton()
+ }
+ if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
+ memberSupportButton()
+ }
+ if groupInfo.canModerate {
+ GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ }
+ if groupInfo.membership.memberActive
+ && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
+ UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ }
+ } header: {
+ Text("")
+ }
+
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@@ -96,19 +114,6 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
- if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
- sendReceiptsOption()
- } else {
- sendReceiptsOptionDisabled()
- }
-
- NavigationLink {
- ChatWallpaperEditorSheet(chat: chat)
- } label: {
- Label("Chat theme", systemImage: "photo")
- }
- } header: {
- Text("")
} footer: {
let label: LocalizedStringKey = (
groupInfo.businessChat == nil
@@ -118,56 +123,64 @@ struct GroupChatInfoView: View {
Text(label)
.foregroundColor(theme.colors.secondary)
}
-
+
Section {
+ if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
+ sendReceiptsOption()
+ } else {
+ sendReceiptsOptionDisabled()
+ }
+ NavigationLink {
+ ChatWallpaperEditorSheet(chat: chat)
+ } label: {
+ Label("Chat theme", systemImage: "photo")
+ }
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
Text("Delete chat messages from your device.")
}
-
- Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
- if groupInfo.canAddMembers {
- if groupInfo.businessChat == nil {
- groupLinkButton()
+
+ if !groupInfo.nextConnectPrepared {
+ Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
+ if groupInfo.canAddMembers {
+ if (chat.chatInfo.incognito) {
+ Label("Invite members", systemImage: "plus")
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .onTapGesture { alert = .cantInviteIncognitoAlert }
+ } else {
+ addMembersButton()
+ }
}
- if (chat.chatInfo.incognito) {
- Label("Invite members", systemImage: "plus")
- .foregroundColor(Color(uiColor: .tertiaryLabel))
- .onTapGesture { alert = .cantInviteIncognitoAlert }
- } else {
- addMembersButton()
- }
- }
- if members.count > 8 {
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.chatViewName.localizedLowercase.contains(s) }
- MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
- ForEach(filteredMembers) { member in
- ZStack {
- NavigationLink {
- memberInfoView(member)
- } label: {
- EmptyView()
- }
- .opacity(0)
- MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
+ 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),
+ scrollToItemId: $scrollToItemId,
+ user: true,
+ alert: $alert
+ )
+ ForEach(filteredMembers) { member in
+ MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert)
}
}
}
-
+
Section {
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
}
- if groupInfo.membership.memberCurrent {
+ if groupInfo.membership.memberCurrentOrPending {
leaveGroupButton()
}
}
-
+
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -179,7 +192,7 @@ struct GroupChatInfoView: View {
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
-
+
if progressIndicator {
ProgressView().scaleEffect(2)
}
@@ -197,7 +210,6 @@ struct GroupChatInfoView: View {
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
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 mkAlert(title: title, message: error)
}
}
@@ -207,8 +219,9 @@ struct GroupChatInfoView: View {
}
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
do {
- if let link = try apiGetGroupLink(groupInfo.groupId) {
- (groupLink, groupLinkMemberRole) = link
+ if let gLink = try apiGetGroupLink(groupInfo.groupId) {
+ groupLink = gLink
+ groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
@@ -219,19 +232,30 @@ struct GroupChatInfoView: View {
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
+ // show actual display name, alias can be edited in this view
+ let displayName = (cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName).trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
- Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(4)
.padding(.bottom, 2)
- if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
+ if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
.multilineTextAlignment(.center)
- .lineLimit(8)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
+ .multilineTextAlignment(.center)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .center)
@@ -253,7 +277,7 @@ struct GroupChatInfoView: View {
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
-
+
private func setGroupAlias() {
Task {
do {
@@ -267,7 +291,7 @@ struct GroupChatInfoView: View {
}
}
}
-
+
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
@@ -276,7 +300,9 @@ struct GroupChatInfoView: View {
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
- muteButton(width: buttonWidth)
+ if let nextNtfMode = chat.chatInfo.nextNtfMode {
+ muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
+ }
}
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -290,9 +316,9 @@ struct GroupChatInfoView: View {
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
- @ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
- if chat.chatInfo.incognito {
- ZStack {
+ private func addMembersActionButton(width: CGFloat) -> some View {
+ ZStack {
+ if chat.chatInfo.incognito {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
@@ -304,10 +330,7 @@ struct GroupChatInfoView: View {
}
.frame(width: 1, height: 1)
.hidden()
- }
- .disabled(!groupInfo.ready)
- } else {
- ZStack {
+ } else {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
@@ -320,17 +343,17 @@ struct GroupChatInfoView: View {
.frame(width: 1, height: 1)
.hidden()
}
- .disabled(!groupInfo.ready)
}
+ .disabled(!groupInfo.ready)
}
- private func muteButton(width: CGFloat) -> some View {
- InfoViewButton(
- image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
- title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
+ private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
+ return InfoViewButton(
+ image: nextNtfMode.iconFilled,
+ title: "\(nextNtfMode.text(mentions: true))",
width: width
) {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
+ toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!groupInfo.ready)
}
@@ -353,25 +376,23 @@ struct GroupChatInfoView: View {
.onAppear {
searchFocussed = false
Task {
- let groupMembers = await apiListMembers(groupInfo.groupId)
- await MainActor.run {
- chatModel.groupMembers = groupMembers.map { GMember.init($0) }
- chatModel.populateGroupMembersIndexes()
- }
+ await chatModel.loadGroupMembers(groupInfo)
}
}
}
private struct MemberRowView: View {
+ var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
@EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
var body: some View {
let member = groupMember.wrapped
- let v = HStack{
+ let v1 = HStack{
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
// TODO server connection status
@@ -387,10 +408,24 @@ struct GroupChatInfoView: View {
Spacer()
memberInfo(member)
}
-
+
+ let v = ZStack {
+ if user {
+ v1
+ } else {
+ NavigationLink {
+ memberInfoView()
+ } label: {
+ EmptyView()
+ }
+ .opacity(0)
+ v1
+ }
+ }
+
if user {
v
- } else if groupInfo.membership.memberRole >= .admin {
+ } else if groupInfo.membership.memberRole >= .moderator {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
@@ -412,6 +447,11 @@ struct GroupChatInfoView: View {
}
}
+ private func memberInfoView() -> some View {
+ GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId)
+ .navigationBarHidden(false)
+ }
+
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
@@ -428,7 +468,7 @@ struct GroupChatInfoView: View {
.foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
- if [.owner, .admin, .observer].contains(role) {
+ if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
.foregroundColor(theme.colors.secondary)
}
@@ -474,7 +514,7 @@ struct GroupChatInfoView: View {
private func removeSwipe(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .trailing) {
Button(role: .destructive) {
- alert = .removeMemberAlert(mem: member)
+ showRemoveMemberAlert(groupInfo, member)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
@@ -491,11 +531,6 @@ struct GroupChatInfoView: View {
}
}
- private func memberInfoView(_ groupMember: GMember) -> some View {
- GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
- .navigationBarHidden(false)
- }
-
private func groupLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
@@ -521,15 +556,99 @@ struct GroupChatInfoView: View {
.navigationBarTitleDisplayMode(.large)
}
+ struct UserSupportChatNavLink: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ @EnvironmentObject var chatModel: ChatModel
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ HStack {
+ Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag")
+ Spacer()
+ if chat.supportUnreadCount > 0 {
+ UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
+ }
+ }
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
+ }
+ }
+ }
+ }
+
+ private func memberSupportButton() -> some View {
+ NavigationLink {
+ MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ .navigationBarTitle("Chats with members")
+ .modifier(ThemedBackground())
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Label(
+ "Chats with members",
+ systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag"
+ )
+ Spacer()
+ if chat.supportUnreadCount > 0 {
+ UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
+ }
+ }
+ }
+ }
+
+ struct GroupReportsChatNavLink: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ @EnvironmentObject var chatModel: ChatModel
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ HStack {
+ Label {
+ Text("Member reports")
+ } icon: {
+ Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red)
+ }
+ Spacer()
+ if chat.chatStats.reportsCount > 0 {
+ UnreadBadge(count: chat.chatStats.reportsCount, color: .red)
+ }
+ }
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report))
+ }
+ }
+ }
+ }
+
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
- .navigationBarTitle("Group profile")
- .modifier(ThemedBackground())
- .navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
}
@@ -571,9 +690,9 @@ struct GroupChatInfoView: View {
}
}
- @ViewBuilder private func leaveGroupButton() -> some View {
+ private func leaveGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
- Button(role: .destructive) {
+ return Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
Label(label, systemImage: "rectangle.portrait.and.arrow.right")
@@ -640,14 +759,13 @@ struct GroupChatInfoView: View {
}
private func sendReceiptsOption() -> some View {
- Picker(selection: $sendReceipts) {
+ WrappedPicker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
Text(opt.text)
}
} label: {
Label("Send receipts", systemImage: "checkmark.message")
}
- .frame(height: 36)
.onChange(of: sendReceipts) { _ in
setSendReceipts()
}
@@ -670,32 +788,50 @@ struct GroupChatInfoView: View {
alert = .largeGroupReceiptsDisabled
}
}
+}
- private func removeMemberAlert(_ mem: GroupMember) -> Alert {
- let messageLabel: LocalizedStringKey = (
+func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
+ showAlert(
+ NSLocalizedString("Remove member?", comment: "alert title"),
+ message:
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(messageLabel),
- primaryButton: .destructive(Text("Remove")) {
- Task {
- do {
- let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
- await MainActor.run {
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
- }
- } catch let error {
- logger.error("apiRemoveMember error: \(responseError(error))")
- let a = getErrorAlert(error, "Error removing member")
- alert = .error(title: a.title, error: a.message)
+ ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
+ : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
+ },
+ UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
+ },
+ cancelAlertAction
+ ]}
+ )
+}
+
+func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
+ Task {
+ do {
+ let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages)
+ await MainActor.run {
+ ChatModel.shared.updateGroup(updatedGroupInfo)
+ updatedMembers.forEach { updatedMember in
+ _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
+ if withMessages {
+ ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo)
}
}
- },
- secondaryButton: .cancel()
- )
+ dismiss?()
+ }
+ } catch let error {
+ logger.error("apiRemoveMembers error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error removing member", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
}
}
@@ -712,11 +848,11 @@ struct GroupPreferencesButton: View {
@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(
@@ -734,7 +870,7 @@ struct GroupPreferencesButton: View {
creatingGroup ? "Save" : "Save and notify group members",
comment: "alert button"
)
-
+
if groupInfo.fullGroupPreferences != preferences {
showAlert(
title: NSLocalizedString("Save preferences?", comment: "alert title"),
@@ -752,7 +888,7 @@ struct GroupPreferencesButton: View {
}
}
}
-
+
private func savePreferences() {
Task {
do {
@@ -769,7 +905,6 @@ struct GroupPreferencesButton: View {
}
}
}
-
}
@@ -792,6 +927,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
+ scrollToItemId: Binding.constant(nil),
onSearch: {},
localAlias: ""
)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index 39288e2d52..bc1ac4ab65 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -10,12 +10,14 @@ import SwiftUI
import SimpleXChat
struct GroupLinkView: View {
+ @EnvironmentObject var theme: AppTheme
var groupId: Int64
- @Binding var groupLink: String?
+ @Binding var groupLink: GroupLink?
@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
@@ -33,16 +35,23 @@ struct GroupLinkView: View {
}
var body: some View {
- if creatingGroup {
- groupLinkView()
- .navigationBarBackButtonHidden()
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button ("Continue") { linkCreatedCb?() }
+ ZStack {
+ if creatingGroup {
+ groupLinkView()
+ .navigationBarBackButtonHidden()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button ("Continue") { linkCreatedCb?() }
+ }
}
- }
- } else {
- groupLinkView()
+ } else {
+ groupLinkView()
+ }
+ if creatingLink {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity)
+ }
}
}
@@ -69,10 +78,21 @@ struct GroupLinkView: View {
}
}
.frame(height: 36)
- SimpleXLinkQRCode(uri: groupLink)
- .id("simplex-qrcode-view-for-\(groupLink)")
+ SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink)
+ .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))")
+ if groupLink.shouldBeUpgraded {
+ Button {
+ upgradeAndShareLinkAlert()
+ } label: {
+ Label("Upgrade link", systemImage: "arrow.up")
+ }
+ }
Button {
- showShareSheet(items: [simplexChatLink(groupLink)])
+ if groupLink.shouldBeUpgraded {
+ upgradeAndShareLinkAlert(groupLink: groupLink)
+ } else {
+ groupLink.shareAddress(short: showShortLink)
+ }
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
@@ -87,11 +107,10 @@ struct GroupLinkView: View {
Label("Create link", systemImage: "link.badge.plus")
}
.disabled(creatingLink)
- if creatingLink {
- ProgressView()
- .scaleEffect(2)
- .frame(maxWidth: .infinity)
- }
+ }
+ } header: {
+ if let groupLink, groupLink.connLinkContact.connShortLink != nil {
+ ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink)
}
}
.alert(item: $alert) { alert in
@@ -118,7 +137,7 @@ struct GroupLinkView: View {
.onChange(of: groupLinkMemberRole) { _ in
Task {
do {
- _ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
+ groupLink = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
} catch let error {
let a = getErrorAlert(error, "Error updating group link")
alert = .error(title: a.title, error: a.message)
@@ -139,10 +158,10 @@ struct GroupLinkView: View {
Task {
do {
creatingLink = true
- let link = try await apiCreateGroupLink(groupId)
+ let gLink = try await apiCreateGroupLink(groupId)
await MainActor.run {
creatingLink = false
- (groupLink, groupLinkMemberRole) = link
+ groupLink = gLink
}
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
@@ -154,12 +173,61 @@ struct GroupLinkView: View {
}
}
}
+
+ private func upgradeAndShareLinkAlert(groupLink: GroupLink? = nil) {
+ showAlert(
+ NSLocalizedString("Upgrade group link?", comment: "alert message"),
+ message: NSLocalizedString("The link will be short, and group profile will be shared via the link.", comment: "alert message"),
+ actions: {
+ var actions = [UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in
+ addShortLink(shareOnCompletion: groupLink != nil)
+ }]
+ if let groupLink {
+ actions.append(UIAlertAction(title: NSLocalizedString("Share old link", comment: "alert button"), style: .default) { _ in
+ groupLink.shareAddress(short: showShortLink)
+ })
+ }
+ actions.append(cancelAlertAction)
+ return actions
+ }
+ )
+ }
+
+ private func addShortLink(shareOnCompletion: Bool = false) {
+ Task {
+ do {
+ creatingLink = true
+ let gLink = try await apiAddGroupShortLink(groupId)
+ await MainActor.run {
+ creatingLink = false
+ groupLink = gLink
+ if shareOnCompletion, let gLink {
+ gLink.shareAddress(short: showShortLink)
+ }
+ }
+ } catch let error {
+ logger.error("apiAddGroupShortLink: \(responseError(error))")
+ await MainActor.run {
+ creatingLink = false
+ let a = getErrorAlert(error, "Error adding short link")
+ alert = .error(title: a.title, error: a.message)
+ }
+ }
+ }
+ }
}
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: GroupLink? = GroupLink(
+ userContactLinkId: 1,
+ connLinkContact: 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),
+ shortLinkDataSet: false,
+ shortLinkLargeDataSet: false,
+ groupLinkId: "abc",
+ acceptMemberRole: .member
+ )
+ @State var noGroupLink: GroupLink? = nil
return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
@@ -167,4 +235,3 @@ struct GroupLinkView_Previews: PreviewProvider {
}
}
}
-
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index 102f0333be..207c2170a3 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -16,7 +16,9 @@ struct GroupMemberInfoView: View {
@State var groupInfo: GroupInfo
@ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
var navigation: Bool = false
+ var openedFromSupportChat: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@@ -25,7 +27,6 @@ struct GroupMemberInfoView: View {
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
- @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true
@State private var progressIndicator = false
@@ -35,12 +36,10 @@ struct GroupMemberInfoView: View {
case unblockMemberAlert(mem: GroupMember)
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
- case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
- case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
@@ -51,12 +50,10 @@ struct GroupMemberInfoView: View {
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
- case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert"
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)"
@@ -103,6 +100,11 @@ struct GroupMemberInfoView: View {
if member.memberActive {
Section {
+ if !openedFromSupportChat
+ && groupInfo.membership.memberRole >= .moderator
+ && (member.memberRole < .moderator || member.supportChat != nil) {
+ MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
+ }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@@ -156,7 +158,15 @@ struct GroupMemberInfoView: View {
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
- // TODO network connection status
+ if let subStatus = connStats.subStatus {
+ SubStatusRow(status: subStatus)
+ .onTapGesture {
+ showAlert(
+ NSLocalizedString("Network status", comment: "alert title"),
+ message: subStatus.statusExplanation
+ )
+ }
+ }
Button("Change receiving address") {
alert = .switchAddressAlert
}
@@ -178,7 +188,7 @@ struct GroupMemberInfoView: View {
}
}
- if groupInfo.membership.memberRole >= .admin {
+ if groupInfo.membership.memberRole >= .moderator {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
@@ -195,8 +205,9 @@ struct GroupMemberInfoView: View {
Button ("Debug delivery") {
Task {
do {
- let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
- await MainActor.run { alert = .queueInfo(info: info) }
+ if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) {
+ await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
+ }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
@@ -260,25 +271,22 @@ struct GroupMemberInfoView: View {
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
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 .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
- case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
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) }
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
.onChange(of: chat.chatInfo) { c in
- if case let .group(gI) = chat.chatInfo {
+ if case let .group(gI, _) = chat.chatInfo {
groupInfo = gI
}
}
@@ -345,10 +353,8 @@ struct GroupMemberInfoView: View {
Button {
planAndConnect(
contactLink,
- showAlert: { alert = .planAndConnectAlert(alert: $0) },
- showActionSheet: { sheet = $0 },
- dismiss: true,
- incognito: nil
+ theme: theme,
+ dismiss: true
)
} label: {
Label("Connect", systemImage: "link")
@@ -366,14 +372,8 @@ struct GroupMemberInfoView: View {
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
- do {
- let chat = try await apiGetChat(type: .direct, id: contactId)
- chatModel.addChat(chat)
- ItemsModel.shared.loadOpenChat(chat.id) {
- dismissAllSheets(animated: true)
- }
- } catch let error {
- logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
+ ItemsModel.shared.loadOpenChat("@\(contactId)") {
+ dismissAllSheets(animated: true)
}
}
}
@@ -401,7 +401,6 @@ struct GroupMemberInfoView: View {
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
- NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
@@ -451,35 +450,70 @@ struct GroupMemberInfoView: View {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
+ // show alias if set, alias cannot be edited in this view
+ let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if mem.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
- + Text(mem.displayName)
+ + Text(displayName)
.font(.largeTitle)
)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
- Text(mem.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
- if mem.fullName != "" && mem.fullName != mem.displayName {
+ if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(mem.fullName)
.font(.title2)
.multilineTextAlignment(.center)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = mem.memberProfile.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ Text(descr)
+ .font(.subheadline)
+ .multilineTextAlignment(.center)
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
+ struct MemberInfoSupportChatNavLink: View {
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ var member: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped)
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ Label("Chat with member", systemImage: "flag")
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
+ }
+ }
+ }
+ }
+
private func verifyCodeButton(_ code: String) -> some View {
let member = groupMember.wrapped
return NavigationLink {
@@ -542,7 +576,11 @@ struct GroupMemberInfoView: View {
}
}
if canRemove {
- removeMemberButton(mem)
+ if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
+ deleteMemberMessagesButton(mem)
+ } else {
+ removeMemberButton(mem)
+ }
}
}
}
@@ -597,38 +635,32 @@ struct GroupMemberInfoView: View {
private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
- alert = .removeMemberAlert(mem: mem)
+ showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(.red)
}
}
- private func removeMemberAlert(_ mem: GroupMember) -> 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(label),
- primaryButton: .destructive(Text("Remove")) {
- Task {
- do {
- let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
- await MainActor.run {
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
- dismiss()
- }
- } catch let error {
- logger.error("apiRemoveMember error: \(responseError(error))")
- let a = getErrorAlert(error, "Error removing member")
- alert = .error(title: a.title, error: a.message)
- }
- }
- },
- secondaryButton: .cancel()
+ private func deleteMemberMessagesButton(_ mem: GroupMember) -> some View {
+ Button(role: .destructive) {
+ showDeleteMemberMessagesAlert(mem)
+ } label: {
+ Label("Delete member messages", systemImage: "trash")
+ .foregroundColor(.red)
+ }
+ }
+
+ func showDeleteMemberMessagesAlert(_ mem: GroupMember) {
+ showAlert(
+ NSLocalizedString("Delete member messages?", comment: "alert title"),
+ message: NSLocalizedString("Member messages will be deleted - this cannot be undone!", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Delete messages", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
+ },
+ cancelAlertAction
+ ]}
)
}
@@ -647,14 +679,16 @@ struct GroupMemberInfoView: View {
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)
}
@@ -806,12 +840,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))")
}
}
}
@@ -821,7 +857,8 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
chat: Chat.sampleData,
- groupMember: GMember.sampleData
+ groupMember: GMember.sampleData,
+ scrollToItemId: Binding.constant(nil)
)
}
}
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..cdbed7fe30
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
@@ -0,0 +1,271 @@
+//
+// 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 im: ItemsModel
+ 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(theme.colors.background)
+
+ 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
+ }
+ }
+
+ func contextMemberFilter(_ member: GroupMember) -> Bool {
+ switch im.secondaryIMFilter {
+ case nil:
+ return true
+ case let .groupChatScopeContext(groupScopeInfo):
+ switch (groupScopeInfo) {
+ case let .memberSupport(groupMember_):
+ if let scopeMember = groupMember_ {
+ return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId
+ } else {
+ return member.memberRole >= .moderator
+ }
+ case .reports:
+ return false
+ }
+ case .msgContentTagContext:
+ return false
+ }
+ }
+
+ private func filteredMembers() -> [GMember] {
+ let s = mentionName.lowercased()
+ return sortedMembers.filter {
+ contextMemberFilter($0.wrapped)
+ && (s.isEmpty || $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 9ef53258aa..55b1dc6d2e 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -30,6 +30,14 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
+ Section {
+ MemberAdmissionButton(
+ groupInfo: $groupInfo,
+ admission: groupInfo.groupProfile.memberAdmission_,
+ currentAdmission: groupInfo.groupProfile.memberAdmission_,
+ creatingGroup: creatingGroup
+ )
+ }
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
@@ -37,6 +45,7 @@ struct GroupPreferencesView: View {
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
featureSection(.files, $preferences.files.enable, $preferences.files.role)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
+ featureSection(.reports, $preferences.reports.enable)
featureSection(.history, $preferences.history.enable)
if groupInfo.isOwner {
@@ -89,6 +98,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,
@@ -138,6 +148,66 @@ struct GroupPreferencesView: View {
}
}
+struct MemberAdmissionButton: View {
+ @Binding var groupInfo: GroupInfo
+ @State var admission: GroupMemberAdmission
+ @State var currentAdmission: GroupMemberAdmission
+ var creatingGroup: Bool = false
+
+ var body: some View {
+ NavigationLink {
+ MemberAdmissionView(
+ groupInfo: $groupInfo,
+ admission: $admission,
+ currentAdmission: currentAdmission,
+ creatingGroup: creatingGroup,
+ saveAdmission: saveAdmission
+ )
+ .navigationBarTitle("Member admission")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ .onDisappear {
+ let saveText = NSLocalizedString(
+ creatingGroup ? "Save" : "Save and notify group members",
+ comment: "alert button"
+ )
+
+ if groupInfo.groupProfile.memberAdmission_ != admission {
+ showAlert(
+ title: NSLocalizedString("Save admission settings?", comment: "alert title"),
+ buttonTitle: saveText,
+ buttonAction: { saveAdmission() },
+ cancelButton: true
+ )
+ }
+ }
+ } label: {
+ if creatingGroup {
+ Text("Set member admission")
+ } else {
+ Label("Member admission", systemImage: "switch.2")
+ }
+ }
+ }
+
+ private func saveAdmission() {
+ Task {
+ do {
+ var gp = groupInfo.groupProfile
+ gp.memberAdmission = admission
+ let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
+ await MainActor.run {
+ groupInfo = gInfo
+ ChatModel.shared.updateGroup(gInfo)
+ currentAdmission = admission
+ }
+ } catch {
+ logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))")
+ }
+ }
+ }
+}
+
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 1617edd11f..69587c0152 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -26,6 +26,8 @@ struct GroupProfileView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
+ @State private var shortDescr: String = ""
+ @State private var currentProfileHash: Int?
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@@ -34,60 +36,54 @@ struct GroupProfileView: View {
@FocusState private var focusDisplayName
var body: some View {
- return VStack(alignment: .leading) {
- Text("Group profile is stored on members' devices, not on the servers.")
- .padding(.vertical)
+ List {
+ EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource)
+ .if(!focusDisplayName) { $0.padding(.top) }
- ZStack(alignment: .center) {
- ZStack(alignment: .topTrailing) {
- profileImageView(groupProfile.image)
- if groupProfile.image != nil {
- Button {
- groupProfile.image = nil
- } label: {
- Image(systemName: "multiply")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12)
- }
- }
- }
-
- editImageButton { showChooseSource = true }
- }
- .frame(maxWidth: .infinity, alignment: .center)
-
- VStack(alignment: .leading) {
- ZStack(alignment: .topLeading) {
- if !validNewProfileName() {
+ Section {
+ HStack {
+ TextField("Group display name", text: $groupProfile.displayName)
+ .focused($focusDisplayName)
+ if !validNewProfileName {
Button {
alert = .invalidName(validName: mkValidName(groupProfile.displayName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
- } else {
- Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
- profileNameTextEdit("Group display name", $groupProfile.displayName)
- .focused($focusDisplayName)
}
- .padding(.bottom)
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
- profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
- .padding(.bottom)
+ TextField("Group full name (optional)", text: $groupProfile.fullName)
}
- HStack(spacing: 20) {
- Button("Cancel") { dismiss() }
- Button("Save group profile") { saveProfile() }
- .disabled(!canUpdateProfile())
+ HStack {
+ TextField("Short description", text: $shortDescr)
+ if !shortDescrFitsLimit() {
+ Button {
+ showAlert(NSLocalizedString("Description too large", comment: "alert title"))
+ } label: {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+ }
}
+ } footer: {
+ Text("Group profile is stored on members' devices, not on the servers.")
}
- .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
+ Section {
+ Button("Reset") {
+ groupProfile = groupInfo.groupProfile
+ shortDescr = groupInfo.groupProfile.shortDescr ?? ""
+ currentProfileHash = groupProfile.hashValue
+ }
+ .disabled(
+ currentProfileHash == groupProfile.hashValue &&
+ (groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces)
+ )
+ Button("Save group profile", action: saveProfile)
+ .disabled(!canUpdateProfile)
+ }
}
- .padding()
- .frame(maxHeight: .infinity, alignment: .top)
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -95,6 +91,11 @@ struct GroupProfileView: View {
Button("Choose from library") {
showImagePicker = true
}
+ if UIPasteboard.general.hasImages {
+ Button("Paste image") {
+ chosenImage = UIPasteboard.general.image
+ }
+ }
}
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
@@ -120,8 +121,21 @@ struct GroupProfileView: View {
}
}
.onAppear {
+ currentProfileHash = groupProfile.hashValue
+ shortDescr = groupInfo.groupProfile.shortDescr ?? ""
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- focusDisplayName = true
+ withAnimation { focusDisplayName = true }
+ }
+ }
+ .onDisappear {
+ if canUpdateProfile {
+ showAlert(
+ title: NSLocalizedString("Save group profile?", comment: "alert title"),
+ message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"),
+ buttonAction: saveProfile,
+ cancelButton: true
+ )
}
}
.alert(item: $alert) { a in
@@ -135,30 +149,39 @@ struct GroupProfileView: View {
return createInvalidNameAlert(name, $groupProfile.displayName)
}
}
- .contentShape(Rectangle())
- .onTapGesture { hideKeyboard() }
+ .navigationBarTitle("Group profile")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
}
- private func canUpdateProfile() -> Bool {
- groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName()
+ private var canUpdateProfile: Bool {
+ (
+ currentProfileHash != groupProfile.hashValue ||
+ (groupProfile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces)
+ ) &&
+ groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
+ validNewProfileName &&
+ shortDescrFitsLimit()
}
- private func validNewProfileName() -> Bool {
+ private var validNewProfileName: Bool {
groupProfile.displayName == groupInfo.groupProfile.displayName
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
}
- func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View {
- TextField(label, text: name)
- .padding(.leading, 32)
+ private func shortDescrFitsLimit() -> Bool {
+ chatJsonLength(shortDescr) <= MAX_BIO_LENGTH_BYTES
}
func saveProfile() {
Task {
do {
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
+ groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces)
+ groupProfile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run {
+ currentProfileHash = groupProfile.hashValue
groupInfo = gInfo
chatModel.updateGroup(gInfo)
dismiss()
@@ -174,6 +197,9 @@ struct GroupProfileView: View {
struct GroupProfileView_Previews: PreviewProvider {
static var previews: some View {
- GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
+ GroupProfileView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ groupProfile: GroupProfile.sampleData
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
index 8dfc32f6ea..f58f2c213d 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
@@ -18,6 +18,7 @@ struct GroupWelcomeView: View {
@State private var editMode = true
@FocusState private var keyboardVisible: Bool
@State private var showSaveDialog = false
+ @State private var showSecrets: Set = []
let maxByteCount = 1200
@@ -58,7 +59,8 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
- messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
+ let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -155,6 +157,9 @@ struct GroupWelcomeView: View {
struct GroupWelcomeView_Previews: PreviewProvider {
static var previews: some View {
- GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
+ GroupProfileView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ groupProfile: GroupProfile.sampleData
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift
new file mode 100644
index 0000000000..d80615b5d2
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift
@@ -0,0 +1,93 @@
+//
+// MemberAdmissionView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [
+ (nil, "off"),
+ (.all, "all")
+]
+
+struct MemberAdmissionView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Binding var groupInfo: GroupInfo
+ @Binding var admission: GroupMemberAdmission
+ var currentAdmission: GroupMemberAdmission
+ let creatingGroup: Bool
+ let saveAdmission: () -> Void
+ @State private var showSaveDialogue = false
+
+ var body: some View {
+ let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
+ VStack {
+ List {
+ admissionSection(
+ NSLocalizedString("Review members", comment: "admission stage"),
+ NSLocalizedString("Review members before admitting (\"knocking\").", comment: "admission stage description"),
+ $admission.review
+ )
+
+ if groupInfo.isOwner {
+ Section {
+ Button("Reset") { admission = currentAdmission }
+ Button(saveText) { saveAdmission() }
+ }
+ .disabled(currentAdmission == admission)
+ }
+ }
+ }
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ if currentAdmission == admission {
+ dismiss()
+ } else {
+ showSaveDialogue = true
+ }
+ })
+ .confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) {
+ Button(saveText) {
+ saveAdmission()
+ dismiss()
+ }
+ Button("Exit without saving") {
+ admission = currentAdmission
+ dismiss()
+ }
+ }
+ }
+
+ private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding) -> some View {
+ Section {
+ if groupInfo.isOwner {
+ Picker(admissionStageStr, selection: memberCriteria) {
+ ForEach(memberCriterias, id: \.criteria) { mc in
+ Text(mc.text)
+ }
+ }
+ .frame(height: 36)
+ } else {
+ infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value"))
+ }
+ } footer: {
+ Text(admissionStageDescrStr)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+#Preview {
+ MemberAdmissionView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ admission: Binding.constant(GroupMemberAdmission.sampleData),
+ currentAdmission: GroupMemberAdmission.sampleData,
+ creatingGroup: false,
+ saveAdmission: {}
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
new file mode 100644
index 0000000000..23001e64bf
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
@@ -0,0 +1,44 @@
+//
+// MemberSupportChatToolbar.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 01.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct MemberSupportChatToolbar: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ var groupMember: GroupMember
+ var imageSize: CGFloat = 32
+
+ var body: some View {
+ return HStack {
+ MemberProfileImage(groupMember, size: imageSize)
+ .padding(.trailing, 4)
+ let t = Text(groupMember.chatViewName).font(.headline)
+ (groupMember.verified ? memberVerifiedShield + t : t)
+ .lineLimit(1)
+ }
+ .foregroundColor(theme.colors.onBackground)
+ .frame(width: 220)
+ }
+
+ private var memberVerifiedShield: Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .baselineOffset(1)
+ .kerning(-2)
+ }
+}
+
+#Preview {
+ MemberSupportChatToolbar(
+ groupMember: GroupMember.sampleData
+ )
+ .environmentObject(CurrentColors.toAppTheme())
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
new file mode 100644
index 0000000000..75a6840c4e
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
@@ -0,0 +1,297 @@
+//
+// MemberSupportView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct MemberSupportView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State private var searchText: String = ""
+ @FocusState private var searchFocussed
+ var groupInfo: GroupInfo
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ viewBody()
+ .onAppear {
+ Task {
+ await chatModel.loadGroupMembers(groupInfo)
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ Task {
+ await chatModel.loadGroupMembers(groupInfo)
+ }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func viewBody() -> some View {
+ let membersWithChats = sortedMembersWithChats()
+ let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
+ let filteredMembersWithChats = s == ""
+ ? membersWithChats
+ : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
+
+ if membersWithChats.isEmpty {
+ Text("No chats with members")
+ .foregroundColor(.secondary)
+ } else {
+ List {
+ searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
+ .padding(.leading, 8)
+ ForEach(filteredMembersWithChats) { memberWithChat in
+ MemberSupportChatNavLink(
+ groupInfo: groupInfo,
+ memberWithChat: memberWithChat,
+ scrollToItemId: $scrollToItemId
+ )
+ }
+ }
+ }
+ }
+
+ struct MemberSupportChatNavLink: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State private var memberSupportChatNavLinkActive = false
+ var groupInfo: GroupInfo
+ var memberWithChat: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ ZStack {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped)
+ Button {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) {
+ memberSupportChatNavLinkActive = true
+ }
+ } label: {
+ SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo)
+ }
+
+ NavigationLink(isActive: $memberSupportChatNavLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ .if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in
+ v.swipeActions(edge: .leading, allowsFullSwipe: true) {
+ Button {
+ Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) }
+ } label: {
+ Label("Read", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if memberWithChat.wrapped.memberPending {
+ Button {
+ showAcceptMemberAlert(groupInfo, memberWithChat.wrapped)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ } else {
+ Button {
+ showDeleteMemberSupportChatAlert(groupInfo, memberWithChat.wrapped)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
+ }
+ }
+ }
+
+ func sortedMembersWithChats() -> [GMember] {
+ chatModel.groupMembers
+ .filter {
+ $0.wrapped.supportChat != nil &&
+ $0.wrapped.memberStatus != .memLeft &&
+ $0.wrapped.memberStatus != .memRemoved
+ }
+ .sorted { (m0: GMember, m1: GMember) -> Bool in
+ if m0.wrapped.memberPending != m1.wrapped.memberPending {
+ return m0.wrapped.memberPending
+ }
+
+ let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0
+ let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0
+ if mentions0 != mentions1 {
+ return mentions0
+ }
+
+ let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0
+ let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0
+ if attention0 != attention1 {
+ return attention0
+ }
+
+ let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0
+ let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0
+ if unread0 != unread1 {
+ return unread0
+ }
+
+ return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast)
+ }
+ }
+
+ private struct SupportChatRowView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @ObservedObject var groupMember: GMember
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var groupInfo: GroupInfo
+
+ var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
+
+ var body: some View {
+ let member = groupMember.wrapped
+ HStack{
+ MemberProfileImage(member, size: 38)
+ .padding(.trailing, 2)
+ VStack(alignment: .leading) {
+ let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground)
+ (member.verified ? memberVerifiedShield + t : t)
+ .lineLimit(1)
+ Text(memberStatus(member))
+ .lineLimit(1)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Spacer()
+
+ if member.memberPending {
+ Image(systemName: "flag.fill")
+ .resizable()
+ .scaledToFill()
+ .frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8)
+ .foregroundColor(theme.colors.primary)
+ }
+ if let supportChat = member.supportChat {
+ SupportChatUnreadIndicator(supportChat: supportChat)
+ }
+ }
+ }
+
+ private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
+ if member.activeConn?.connDisabled ?? false {
+ return "disabled"
+ } else if member.activeConn?.connInactive ?? false {
+ return "inactive"
+ } else if member.memberPending {
+ return member.memberStatus.text
+ } else {
+ return LocalizedStringKey(member.memberRole.text)
+ }
+ }
+
+ struct SupportChatUnreadIndicator: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var supportChat: GroupSupportChat
+
+ var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
+
+ private var indicatorTint: Color {
+ if supportChat.mentions > 0 || supportChat.memberAttention > 0 {
+ return theme.colors.primary
+ } else {
+ return theme.colors.secondary
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 2) {
+ if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 {
+ if supportChat.mentions > 0 && supportChat.unread > 1 {
+ Text("\(MENTION_START)")
+ .font(userFont <= .xxxLarge ? .body : .callout)
+ .foregroundColor(indicatorTint)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ .padding(.bottom, 1)
+ }
+ let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1
+ (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread))
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .background(indicatorTint)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
+ }
+ .frame(height: dynamicChatInfoSize)
+ .frame(minWidth: 22)
+ }
+ }
+
+ private var memberVerifiedShield: Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .baselineOffset(2)
+ .kerning(-2)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+func showDeleteMemberSupportChatAlert(_ groupInfo: GroupInfo, _ member: GroupMember) {
+ showAlert(
+ title: NSLocalizedString("Delete chat with member?", comment: "alert title"),
+ buttonTitle: "Delete",
+ buttonAction: { deleteMemberSupportChat(groupInfo, member) },
+ cancelButton: true
+ )
+}
+
+func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) {
+ Task {
+ do {
+ let (gInfo, updatedMember) = try await apiDeleteMemberSupportChat(groupInfo.groupId, member.groupMemberId)
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
+ ChatModel.shared.updateGroup(gInfo)
+ }
+ // TODO member row doesn't get removed from list (upsertGroupMember correctly sets supportChat to nil) - this repopulates list to fix it
+ await ChatModel.shared.loadGroupMembers(gInfo)
+ } catch let error {
+ logger.error("apiDeleteMemberSupportChat error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error deleting chat", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ MemberSupportView(
+ groupInfo: GroupInfo.sampleData,
+ scrollToItemId: Binding.constant(nil)
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift
new file mode 100644
index 0000000000..e2092f7a24
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift
@@ -0,0 +1,44 @@
+//
+// SecondaryChatView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 29.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct SecondaryChatView: View {
+ @Environment(\.dismiss) var dismiss
+ @EnvironmentObject var chatModel: ChatModel
+ @ObservedObject var chat: Chat
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ if let im = chatModel.secondaryIM {
+ ChatView(
+ chat: chat,
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: $scrollToItemId
+ )
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ chatModel.secondaryIM = nil
+ dismiss()
+ })
+ }
+ }
+}
+
+#Preview {
+ SecondaryChatView(
+ chat: Chat(
+ chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)),
+ chatItems: [],
+ chatStats: ChatStats()
+ ),
+ scrollToItemId: Binding.constant(nil)
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift
deleted file mode 100644
index e33adcef58..0000000000
--- a/apps/ios/Shared/Views/Chat/ReverseList.swift
+++ /dev/null
@@ -1,371 +0,0 @@
-//
-// ReverseList.swift
-// SimpleX (iOS)
-//
-// Created by Levitating Pineapple on 11/06/2024.
-// Copyright © 2024 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import Combine
-import SimpleXChat
-
-/// A List, which displays it's items in reverse order - from bottom to top
-struct ReverseList: UIViewControllerRepresentable {
- let items: Array
-
- @Binding var scrollState: ReverseListScrollModel.State
-
- /// Closure, that returns user interface for a given item
- let content: (ChatItem) -> Content
-
- let loadPage: () -> Void
-
- func makeUIViewController(context: Context) -> Controller {
- Controller(representer: self)
- }
-
- func updateUIViewController(_ controller: Controller, context: Context) {
- controller.representer = self
- if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
- controller.view.layer.removeAllAnimations()
- switch destination {
- case .nextPage:
- controller.scrollToNextPage()
- case let .item(id):
- controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
- case .bottom:
- controller.scroll(to: 0, position: .top)
- }
- } else {
- controller.update(items: items)
- }
- }
-
- /// Controller, which hosts SwiftUI cells
- class Controller: UITableViewController {
- private enum Section { case main }
- var representer: ReverseList
- private var dataSource: UITableViewDiffableDataSource!
- private var itemCount: Int = 0
- private let updateFloatingButtons = PassthroughSubject()
- private var bag = Set()
-
- init(representer: ReverseList) {
- self.representer = representer
- super.init(style: .plain)
-
- // 1. Style
- tableView = InvertedTableView()
- tableView.separatorStyle = .none
- tableView.transform = .verticalFlip
- tableView.backgroundColor = .clear
-
- // 2. Register cells
- if #available(iOS 16.0, *) {
- tableView.register(
- UITableViewCell.self,
- forCellReuseIdentifier: cellReuseId
- )
- } else {
- tableView.register(
- HostingCell.self,
- forCellReuseIdentifier: cellReuseId
- )
- }
-
- // 3. Configure data source
- self.dataSource = UITableViewDiffableDataSource(
- tableView: tableView
- ) { (tableView, indexPath, item) -> UITableViewCell? in
- if indexPath.item > self.itemCount - 8 {
- self.representer.loadPage()
- }
- let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
- if #available(iOS 16.0, *) {
- cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
- .margins(.all, 0)
- .minSize(height: 1) // Passing zero will result in system default of 44 points being used
- } else {
- if let cell = cell as? HostingCell {
- cell.set(content: self.representer.content(item), parent: self)
- } else {
- fatalError("Unexpected Cell Type for: \(item)")
- }
- }
- cell.transform = .verticalFlip
- cell.selectionStyle = .none
- cell.backgroundColor = .clear
- return cell
- }
-
- // 4. External state changes will require manual layout updates
- NotificationCenter.default
- .addObserver(
- self,
- selector: #selector(updateLayout),
- name: notificationName,
- object: nil
- )
-
- updateFloatingButtons
- .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
- .sink {
- if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
- ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
- }
- }
- .store(in: &bag)
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) { fatalError() }
-
- deinit { NotificationCenter.default.removeObserver(self) }
-
- @objc private func updateLayout() {
- if #available(iOS 16.0, *) {
- tableView.setNeedsLayout()
- tableView.layoutIfNeeded()
- } else {
- tableView.reloadData()
- }
- }
-
- /// Hides keyboard, when user begins to scroll.
- /// Equivalent to `.scrollDismissesKeyboard(.immediately)`
- override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
- UIApplication.shared
- .sendAction(
- #selector(UIResponder.resignFirstResponder),
- to: nil,
- from: nil,
- for: nil
- )
- NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
- }
-
- override func viewDidAppear(_ animated: Bool) {
- tableView.clipsToBounds = false
- parent?.viewIfLoaded?.clipsToBounds = false
- }
-
- /// Scrolls up
- func scrollToNextPage() {
- tableView.setContentOffset(
- CGPoint(
- x: tableView.contentOffset.x,
- y: tableView.contentOffset.y + tableView.bounds.height
- ),
- animated: true
- )
- Task { representer.scrollState = .atDestination }
- }
-
- /// Scrolls to Item at index path
- /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
- func scroll(to index: Int?, position: UITableView.ScrollPosition) {
- var animated = false
- if #available(iOS 16.0, *) {
- animated = true
- }
- if let index, tableView.numberOfRows(inSection: 0) != 0 {
- tableView.scrollToRow(
- at: IndexPath(row: index, section: 0),
- at: position,
- animated: animated
- )
- } else {
- tableView.setContentOffset(
- CGPoint(x: .zero, y: -InvertedTableView.inset),
- animated: animated
- )
- }
- Task { representer.scrollState = .atDestination }
- }
-
- func update(items: [ChatItem]) {
- var snapshot = NSDiffableDataSourceSnapshot()
- snapshot.appendSections([.main])
- snapshot.appendItems(items)
- dataSource.defaultRowAnimation = .none
- dataSource.apply(
- snapshot,
- animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
- )
- // Sets content offset on initial load
- if itemCount == 0 {
- tableView.setContentOffset(
- CGPoint(x: 0, y: -InvertedTableView.inset),
- animated: false
- )
- }
- itemCount = items.count
- updateFloatingButtons.send()
- }
-
- override func scrollViewDidScroll(_ scrollView: UIScrollView) {
- updateFloatingButtons.send()
- }
-
- func getListState() -> ListState? {
- if let visibleRows = tableView.indexPathsForVisibleRows,
- visibleRows.last?.item ?? 0 < representer.items.count {
- let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
- let topItemDate: Date? =
- if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
- representer.items[lastVisible.item].meta.itemTs
- } else {
- nil
- }
- let bottomItemId: ChatItem.ID? =
- if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
- representer.items[firstVisible.item].id
- } else {
- nil
- }
- return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
- }
- return nil
- }
-
- private func isVisible(indexPath: IndexPath) -> Bool {
- if let relativeFrame = tableView.superview?.convert(
- tableView.rectForRow(at: indexPath),
- from: tableView
- ) {
- relativeFrame.maxY > InvertedTableView.inset &&
- relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
- } else { false }
- }
- }
-
- /// `UIHostingConfiguration` back-port for iOS14 and iOS15
- /// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
- private final class HostingCell: UITableViewCell {
- private let hostingController = UIHostingController(rootView: nil)
-
- /// Updates content of the cell
- /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
- func set(content: Hosted, parent: UIViewController) {
- hostingController.view.backgroundColor = .clear
- hostingController.rootView = content
- if let hostingView = hostingController.view {
- hostingView.invalidateIntrinsicContentSize()
- if hostingController.parent != parent { parent.addChild(hostingController) }
- if !contentView.subviews.contains(hostingController.view) {
- contentView.addSubview(hostingController.view)
- hostingView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- hostingView.leadingAnchor
- .constraint(equalTo: contentView.leadingAnchor),
- hostingView.trailingAnchor
- .constraint(equalTo: contentView.trailingAnchor),
- hostingView.topAnchor
- .constraint(equalTo: contentView.topAnchor),
- hostingView.bottomAnchor
- .constraint(equalTo: contentView.bottomAnchor)
- ])
- }
- if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
- } else {
- fatalError("Hosting View not loaded \(hostingController)")
- }
- }
-
- override func prepareForReuse() {
- super.prepareForReuse()
- hostingController.rootView = nil
- }
- }
-}
-
-typealias ListState = (
- scrollOffset: Double,
- topItemDate: Date?,
- bottomItemId: ChatItem.ID?
-)
-
-/// Manages ``ReverseList`` scrolling
-class ReverseListScrollModel: ObservableObject {
- /// Represents Scroll State of ``ReverseList``
- enum State: Equatable {
- enum Destination: Equatable {
- case nextPage
- case item(ChatItem.ID)
- case bottom
- }
-
- case scrollingTo(Destination)
- case atDestination
- }
-
- @Published var state: State = .atDestination
-
- func scrollToNextPage() {
- state = .scrollingTo(.nextPage)
- }
-
- func scrollToBottom() {
- state = .scrollingTo(.bottom)
- }
-
- func scrollToItem(id: ChatItem.ID) {
- state = .scrollingTo(.item(id))
- }
-}
-
-fileprivate let cellReuseId = "hostingCell"
-
-fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
-
-fileprivate extension CGAffineTransform {
- /// Transform that vertically flips the view, preserving it's location
- static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
-}
-
-extension NotificationCenter {
- static func postReverseListNeedsLayout() {
- NotificationCenter.default.post(
- name: notificationName,
- object: nil
- )
- }
-}
-
-/// Disable animation on iOS 15
-func withConditionalAnimation(
- _ animation: Animation? = .default,
- _ body: () throws -> Result
-) rethrows -> Result {
- if #available(iOS 16.0, *) {
- try withAnimation(animation, body)
- } else {
- try body()
- }
-}
-
-class InvertedTableView: UITableView {
- static let inset = CGFloat(100)
-
- static let insets = UIEdgeInsets(
- top: inset,
- left: .zero,
- bottom: inset,
- right: .zero
- )
-
- override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
- get { .never }
- set { }
- }
-
- override var contentInset: UIEdgeInsets {
- get { Self.insets }
- set { }
- }
-
- override var adjustedContentInset: UIEdgeInsets {
- Self.insets
- }
-}
diff --git a/apps/ios/Shared/Views/Chat/ScrollViewCells.swift b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift
new file mode 100644
index 0000000000..d062627d5b
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift
@@ -0,0 +1,52 @@
+//
+// ScrollViewCells.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 27.01.2025.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+protocol ReusableView {
+ func prepareForReuse()
+}
+
+/// `UIHostingConfiguration` back-port for iOS14 and iOS15
+/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController`
+final class HostingCell: UIView, ReusableView {
+ private let hostingController = UIHostingController(rootView: nil)
+
+ /// Updates content of the cell
+ /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
+ func set(content: Hosted, parent: UIViewController) {
+ hostingController.view.backgroundColor = .clear
+ hostingController.rootView = content
+ if let hostingView = hostingController.view {
+ hostingView.invalidateIntrinsicContentSize()
+ if hostingController.parent != parent { parent.addChild(hostingController) }
+ if !subviews.contains(hostingController.view) {
+ addSubview(hostingController.view)
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ hostingView.leadingAnchor
+ .constraint(equalTo: leadingAnchor),
+ hostingView.trailingAnchor
+ .constraint(equalTo: trailingAnchor),
+ hostingView.topAnchor
+ .constraint(equalTo: topAnchor),
+ hostingView.bottomAnchor
+ .constraint(equalTo: bottomAnchor)
+ ])
+ }
+ if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
+ } else {
+ fatalError("Hosting View not loaded \(hostingController)")
+ }
+ }
+
+ func prepareForReuse() {
+ //super.prepareForReuse()
+ hostingController.rootView = nil
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
index 81498ee497..4855c3ca8d 100644
--- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
+++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
@@ -25,17 +25,20 @@ struct SelectedItemsTopToolbar: View {
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
- let chatItems: [ChatItem]
+ let im: ItemsModel
@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
@@ -50,7 +53,11 @@ struct SelectedItemsBottomToolbar: View {
HStack(alignment: .center) {
Button {
- deleteItems(deleteForEveryoneEnabled)
+ if canArchiveReports {
+ archiveItems()
+ } else {
+ deleteItems(deleteForEveryoneEnabled)
+ }
} label: {
Image(systemName: "trash")
.resizable()
@@ -68,9 +75,9 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
+ .foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red)
}
- .disabled(!moderateEnabled || deleteCountProhibited)
+ .disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil)
.opacity(canModerate ? 1 : 0)
Spacer()
@@ -81,24 +88,24 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
+ .foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary)
}
- .disabled(!forwardEnabled || forwardCountProhibited)
+ .disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
- recheckItems(chatInfo, chatItems, selectedChatItems)
+ recheckItems(chatInfo, im.reversedChatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
- recheckItems(info, chatItems, selectedChatItems)
+ recheckItems(info, im.reversedChatItems, selectedChatItems)
}
- .onChange(of: chatItems) { items in
+ .onChange(of: im.reversedChatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
- recheckItems(chatInfo, chatItems, selected)
+ recheckItems(chatInfo, im.reversedChatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
@@ -109,19 +116,25 @@ struct SelectedItemsBottomToolbar: View {
deleteCountProhibited = count == 0 || count > 200
forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
+ let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo {
+ info
+ } else {
+ nil
+ }
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
- (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
+ (deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
- var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
+ 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, me, onlyOwnGroupItems, fe, sel)
+ return (de, dee, ar, me, onlyOwnGroupItems, fe, sel)
} else {
return r
}
@@ -132,8 +145,8 @@ struct SelectedItemsBottomToolbar: View {
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
- case let .group(groupInfo):
- groupInfo.membership.memberRole >= .admin
+ case let .group(groupInfo, _):
+ groupInfo.membership.memberRole >= .moderator
default: false
}
}
diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
index 7b01fe0300..373311073a 100644
--- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
+++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
@@ -24,85 +24,70 @@ struct VerifyCodeView: View {
}
private func verifyCodeView(_ code: String) -> some View {
- ScrollView {
- let splitCode = splitToParts(code, length: 24)
- VStack(alignment: .leading) {
- Group {
+ let splitCode = splitToParts(code, length: 24)
+ return List {
+ Section {
+ QRCode(uri: code, small: true)
+
+ Text(splitCode)
+ .multilineTextAlignment(.leading)
+ .font(.body.monospaced())
+ .lineLimit(20)
+ .frame(maxWidth: .infinity, alignment: .center)
+ } header: {
+ if connectionVerified {
HStack {
- if connectionVerified {
- Image(systemName: "checkmark.shield")
- .foregroundColor(theme.colors.secondary)
- Text("\(displayName) is verified")
- } else {
- Text("\(displayName) is not verified")
- }
+ Image(systemName: "checkmark.shield").foregroundColor(theme.colors.secondary)
+ Text("\(displayName) is verified").textCase(.none)
}
- .frame(height: 24)
-
- QRCode(uri: code)
- .padding(.horizontal)
-
- Text(splitCode)
- .multilineTextAlignment(.leading)
- .font(.body.monospaced())
- .lineLimit(20)
- .padding(.bottom, 8)
+ } else {
+ Text("\(displayName) is not verified").textCase(.none)
}
- .frame(maxWidth: .infinity, alignment: .center)
-
+ } footer: {
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
- .padding(.bottom)
+ }
- Group {
- if connectionVerified {
- Button {
- verifyCode(nil)
- } label: {
- Label("Clear verification", systemImage: "shield")
- }
- .padding()
- } else {
- HStack {
- NavigationLink {
- ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
- .navigationBarTitleDisplayMode(.large)
- .navigationTitle("Scan code")
- .modifier(ThemedBackground())
- } label: {
- Label("Scan code", systemImage: "qrcode")
- }
- .padding()
- Button {
- verifyCode(code) { verified in
- if !verified { showCodeError = true }
- }
- } label: {
- Label("Mark verified", systemImage: "checkmark.shield")
- }
- .padding()
- .alert(isPresented: $showCodeError) {
- Alert(title: Text("Incorrect security code!"))
- }
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .center)
- }
- .padding()
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
+ Section {
+ if connectionVerified {
Button {
- showShareSheet(items: [splitCode])
+ verifyCode(nil)
} label: {
- Image(systemName: "square.and.arrow.up")
+ Label("Clear verification", systemImage: "shield")
+ }
+ } else {
+ NavigationLink {
+ ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
+ .navigationBarTitleDisplayMode(.large)
+ .navigationTitle("Scan code")
+ .modifier(ThemedBackground())
+ } label: {
+ Label("Scan code", systemImage: "qrcode")
+ }
+ Button {
+ verifyCode(code) { verified in
+ if !verified { showCodeError = true }
+ }
+ } label: {
+ Label("Mark verified", systemImage: "checkmark.shield")
+ }
+ .alert(isPresented: $showCodeError) {
+ Alert(title: Text("Incorrect security code!"))
}
}
}
- .onChange(of: connectionVerified) { _ in
- if connectionVerified { dismiss() }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ showShareSheet(items: [splitCode])
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
}
}
+ .onChange(of: connectionVerified) { _ in
+ if connectionVerified { dismiss() }
+ }
}
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index f1ee4e4c42..4937bca20e 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -66,7 +66,7 @@ struct ChatListNavLink: View {
switch chat.chatInfo {
case let .direct(contact):
contactNavLink(contact)
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
groupNavLink(groupInfo)
case let .local(noteFolder):
noteFolderNavLink(noteFolder)
@@ -90,11 +90,11 @@ struct ChatListNavLink: View {
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
- @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
+ private func contactNavLink(_ contact: Contact) -> some View {
Group {
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
+ if contact.isContactCard {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
@@ -121,31 +121,83 @@ struct ChatListNavLink: View {
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- markReadButton()
- toggleFavoriteButton()
- toggleNtfsButton(chat: chat)
+ .frameCompat(height: dynamicRowHeight)
+ .if(!contact.nextAcceptContactRequest) { v in
+ v.swipeActions(edge: .leading, allowsFullSwipe: true) {
+ markReadButton()
+ toggleFavoriteButton()
+ toggleNtfsButton(chat: chat)
+ }
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
- tagChatButton(chat)
- if !chat.chatItems.isEmpty {
- clearChatButton()
+ if contact.nextAcceptContactRequest {
+ if let contactRequestId = contact.contactRequestId {
+ Button {
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) }
+ } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
+ .tint(theme.colors.primary)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) }
+ } label: {
+ SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ }
+ .tint(.indigo)
+ }
+ Button {
+ AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequestId))
+ } label: {
+ SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply", inverted: oneHandUI)
+ }
+ .tint(.red)
+ } else if let groupDirectInv = contact.groupDirectInv, !groupDirectInv.memberRemoved {
+ Button {
+ acceptMemberContactRequest(contact)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ Button {
+ showRejectMemberContactRequestAlert(contact)
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else {
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ deleteLabel
+ }
+ .tint(.red)
+ }
+ } else {
+ tagChatButton(chat)
+ if !chat.chatItems.isEmpty {
+ clearChatButton()
+ }
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ deleteLabel
+ }
+ .tint(.red)
}
- Button {
- deleteContactDialog(
- chat,
- contact,
- dismissToChatList: false,
- showAlert: { alert = $0 },
- showActionSheet: { actionSheet = $0 },
- showSheetContent: { sheet = $0 }
- )
- } label: {
- deleteLabel
- }
- .tint(.red)
}
- .frame(height: dynamicRowHeight)
}
}
.alert(item: $alert) { $0.alert }
@@ -163,7 +215,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -183,13 +235,13 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
- if (groupInfo.membership.memberCurrent) {
+ if (groupInfo.membership.memberCurrentOrPending) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
@@ -203,7 +255,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
@@ -211,37 +263,46 @@ struct ChatListNavLink: View {
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
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 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
+ let showLeaveGroup = groupInfo.membership.memberCurrentOrPending
+ let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
- if showClearButton, totalNumberOfButtons <= 3 {
+ if showClearButton && totalNumberOfButtons <= 3 {
clearChatButton()
}
- if (showLeaveGroup) {
+
+ if showReportsButton && totalNumberOfButtons <= 3 {
+ archiveAllReportsButton()
+ }
+
+ if showLeaveGroup {
leaveGroupChatButton(groupInfo)
}
-
- if showDeleteGroup {
- if totalNumberOfButtons <= 3 {
+
+ if showDeleteGroup && totalNumberOfButtons <= 3 {
+ deleteGroupChatButton(groupInfo)
+ } else if totalNumberOfButtons > 3 {
+ if showDeleteGroup && !groupInfo.membership.memberActive {
deleteGroupChatButton(groupInfo)
+ moreOptionsButton(false, chat, groupInfo)
} else {
- moreOptionsButton(chat, groupInfo)
+ moreOptionsButton(true, chat, groupInfo)
}
}
}
}
}
- @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
+ private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain(
chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -267,7 +328,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func markReadButton() -> some View {
if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat {
Button {
- Task { await markChatRead(chat) }
+ Task { await markChatRead(ItemsModel.shared, chat) }
} label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
}
@@ -302,14 +363,22 @@ struct ChatListNavLink: View {
}
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
- Button {
- toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
- } label: {
- if chat.chatInfo.ntfsEnabled {
- SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
- } else {
- SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
+ if let nextMode = chat.chatInfo.nextNtfMode {
+ Button {
+ toggleNotifications(chat, enableNtfs: nextMode)
+ } label: {
+ SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
}
+ } else {
+ EmptyView()
+ }
+ }
+
+ private func archiveAllReportsButton() -> some View {
+ Button {
+ AlertManager.shared.showAlert(archiveAllReportsAlert())
+ } label: {
+ SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI)
}
}
@@ -354,15 +423,20 @@ struct ChatListNavLink: View {
)
}
- private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
+ private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
Button {
- var buttons: [Alert.Button] = [
- .default(Text("Clear")) {
- AlertManager.shared.showAlert(clearChatAlert())
- }
- ]
-
- if let gi = groupInfo, gi.canDelete {
+ 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))
})
@@ -372,7 +446,7 @@ struct ChatListNavLink: View {
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
- title: Text("Clear or delete group?"),
+ title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"),
buttons: buttons
),
id: "other options"
@@ -411,36 +485,41 @@ struct ChatListNavLink: View {
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
+ .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
.tint(theme.colors.primary)
- Button {
- Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
- } label: {
- SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) }
+ } label: {
+ SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ }
+ .tint(.indigo)
}
- .tint(.indigo)
Button {
- AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
+ AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest.apiId))
} label: {
- SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
+ SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply", inverted: oneHandUI)
}
.tint(.red)
}
- .frame(height: dynamicRowHeight)
.contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
- Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
- Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
+ Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } }
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } }
+ }
+ Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } }
}
}
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
@@ -458,14 +537,11 @@ struct ChatListNavLink: View {
}
.tint(theme.colors.primary)
}
- .frame(height: dynamicRowHeight)
.appSheet(isPresented: $showContactConnectionInfo) {
- Group {
- if case let .contactConnection(contactConnection) = chat.chatInfo {
- ContactConnectionInfo(contactConnection: contactConnection)
- .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
- .modifier(ThemedBackground(grouped: true))
- }
+ if case let .contactConnection(contactConnection) = chat.chatInfo {
+ ContactConnectionInfo(contactConnection: contactConnection)
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ .modifier(ThemedBackground(grouped: true))
}
}
.contentShape(Rectangle())
@@ -490,6 +566,27 @@ struct ChatListNavLink: View {
)
}
+ 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 {
Alert(
title: Text("Clear conversation?"),
@@ -536,14 +633,14 @@ struct ChatListNavLink: View {
)
}
- private func invalidJSONPreview(_ json: String) -> some View {
+ private func invalidJSONPreview(_ json: Data?) -> some View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
- .frame(height: dynamicRowHeight)
+ .frameCompat(height: dynamicRowHeight)
.onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) {
- invalidJSONView(json)
+ invalidJSONView(dataToString(json))
.environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
}
}
@@ -552,19 +649,38 @@ struct ChatListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok {
- ItemsModel.shared.loadOpenChat(contact.id)
- AlertManager.shared.showAlert(connReqSentAlert(.contact))
+ ItemsModel.shared.loadOpenChat(contact.id) {
+ AlertManager.shared.showAlert(connReqSentAlert(.contact))
+ }
}
}
}
}
-func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
+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(_ contactRequestId: Int64) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
- Task { await rejectContactRequest(contactRequest) }
+ Task { await rejectContactRequest(contactRequestId) }
},
secondaryButton: .cancel()
)
@@ -614,16 +730,17 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
Task {
logger.debug("joinGroup")
do {
- let r = try await apiJoinGroup(groupId)
- switch r {
- case let .joined(groupInfo):
- await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
- case .invitationRemoved:
- AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
- await deleteGroup()
- case .groupNotFound:
- AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
- await deleteGroup()
+ if let r = try await apiJoinGroup(groupId) {
+ switch r {
+ case let .joined(groupInfo):
+ await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
+ case .invitationRemoved:
+ AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
+ await deleteGroup()
+ case .groupNotFound:
+ AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
+ await deleteGroup()
+ }
}
await onComplete()
} catch let error {
@@ -645,7 +762,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
- if let r = error as? ChatResponse,
+ if let r = error as? ChatError,
let alert = getNetworkErrorAlert(r) {
return alert
} else {
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 68e0c57c75..0450bd439c 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -137,6 +137,7 @@ struct UserPickerSheetView: View {
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@EnvironmentObject var theme: AppTheme
@Binding var activeUserPickerSheet: UserPickerSheet?
@State private var searchMode = false
@@ -148,7 +149,11 @@ struct ChatListView: View {
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
-
+ @State private var scrollToItemId: ChatItem.ID? = nil
+
+ // 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
@@ -203,7 +208,17 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .onDisappear() { activeUserPickerSheet = nil }
+ .onAppear {
+ if #unavailable(iOS 16.0), !viewOnScreen {
+ viewOnScreen = true
+ }
+ }
+ .onDisappear {
+ activeUserPickerSheet = nil
+ if #unavailable(iOS 16.0) {
+ viewOnScreen = false
+ }
+ }
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -258,7 +273,7 @@ struct ChatListView: View {
}
} else {
if oneHandUI {
- content().toolbar { bottomToolbarGroup }
+ content().toolbar { bottomToolbarGroup() }
} else {
content().toolbar { topToolbar }
}
@@ -286,9 +301,9 @@ struct ChatListView: View {
}
}
- @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
+ @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
- ToolbarItemGroup(placement: .bottomBar) {
+ ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
@@ -322,9 +337,9 @@ struct ChatListView: View {
}
}
- @ViewBuilder private var chatList: some View {
+ private var chatList: some View {
let cs = filteredChats()
- ZStack {
+ return ZStack {
ScrollViewReader { scrollProxy in
List {
if !chatModel.chats.isEmpty {
@@ -354,13 +369,7 @@ struct ChatListView: View {
.offset(x: -8)
} else {
ForEach(cs, id: \.viewId) { chat in
- VStack(spacing: .zero) {
- Divider()
- .padding(.leading, 16)
- ChatListNavLink(chat: chat, parentSheet: $sheet)
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
- }
+ ChatListNavLink(chat: chat, parentSheet: $sheet)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
@@ -439,7 +448,14 @@ struct ChatListView: View {
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
- ChatView(chat: chat)
+ let im = ItemsModel.shared
+ ChatView(
+ chat: chat,
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: $scrollToItemId
+ )
}
}
@@ -480,7 +496,7 @@ struct ChatListView: View {
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.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
+ case .unread: chat.unreadTag
case .none: true
}
}
@@ -557,6 +573,7 @@ struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@@ -564,8 +581,6 @@ struct ChatListSearchBar: View {
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var parentSheet: SomeSheet?
@State private var ignoreSearchTextChange = false
- @State private var alert: PlanAndConnectAlert?
- @State private var sheet: PlanAndConnectActionSheet?
var body: some View {
VStack(spacing: 12) {
@@ -578,6 +593,9 @@ struct ChatListSearchBar: View {
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
@@ -611,7 +629,7 @@ struct ChatListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
- if case let .simplexLink(linkType, _, smpHosts) = link.format {
+ if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -621,6 +639,8 @@ struct ChatListSearchBar: View {
} else {
if t != "" { // if some other text is pasted, enter search mode
searchFocussed = true
+ } else {
+ ConnectProgressManager.shared.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
@@ -630,12 +650,6 @@ struct ChatListSearchBar: View {
.onChange(of: chatTagsModel.activeFilter) { _ in
searchText = ""
}
- .alert(item: $alert) { a in
- planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
- }
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
- }
}
private func toggleFilterButton() -> some View {
@@ -661,10 +675,12 @@ struct ChatListSearchBar: View {
private func connect(_ link: String) {
planAndConnect(
link,
- showAlert: { alert = $0 },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: false,
- incognito: nil,
+ cleanup: {
+ searchText = ""
+ searchFocussed = false
+ },
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
)
@@ -791,7 +807,7 @@ struct TagsView: View {
}
}
- @ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
+ private func expandedPresetTagsFiltersView() -> some View {
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag)
@@ -882,15 +898,15 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C
chatInfo.chatSettings?.favorite == true
case .contacts:
switch chatInfo {
- case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
+ case let .direct(contact): !contact.isContactCard && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
- case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
+ case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chatInfo {
- case let .group(groupInfo): groupInfo.businessChat == nil
+ case let .group(groupInfo, _): groupInfo.businessChat == nil
default: false
}
case .business:
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 654bb56441..be2c456802 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -24,75 +24,83 @@ struct ChatPreviewView: View {
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
-
+
var body: some View {
let cItem = chat.chatItems.last
- return HStack(spacing: 8) {
- ZStack(alignment: .bottomTrailing) {
- ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
- chatPreviewImageOverlayIcon()
- .padding([.bottom, .trailing], 1)
- }
- .padding(.leading, 4)
-
- let chatTs = if let cItem {
- cItem.meta.itemTs
- } else {
- chat.chatInfo.chatTs
- }
- VStack(spacing: 0) {
- HStack(alignment: .top) {
- chatPreviewTitle()
- Spacer()
- (formatTimestampText(chatTs))
- .font(.subheadline)
- .frame(minWidth: 60, alignment: .trailing)
- .foregroundColor(theme.colors.secondary)
- .padding(.top, 4)
+ return ZStack {
+ HStack(spacing: 8) {
+ ZStack(alignment: .bottomTrailing) {
+ ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
+ chatPreviewImageOverlayIcon()
+ .padding([.bottom, .trailing], 1)
}
- .padding(.bottom, 4)
- .padding(.horizontal, 8)
+ .padding(.leading, 4)
- ZStack(alignment: .topTrailing) {
- let chat = activeContentPreview?.chat ?? chat
- let ci = activeContentPreview?.ci ?? chat.chatItems.last
- let mc = ci?.content.msgContent
+ let chatTs = if let cItem {
+ cItem.meta.itemTs
+ } else {
+ chat.chatInfo.chatTs
+ }
+ VStack(spacing: 0) {
HStack(alignment: .top) {
- let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
- let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
- if let ci, showContentPreview {
- chatItemContentPreview(chat, ci)
+ chatPreviewTitle()
+ Spacer()
+ (formatTimestampText(chatTs))
+ .font(.subheadline)
+ .frame(minWidth: 60, alignment: .trailing)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top, 4)
+ }
+ .padding(.bottom, 4)
+ .padding(.horizontal, 8)
+
+ ZStack(alignment: .topTrailing) {
+ let chat = activeContentPreview?.chat ?? chat
+ let ci = activeContentPreview?.ci ?? chat.chatItems.last
+ let mc = ci?.content.msgContent
+ HStack(alignment: .top) {
+ let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
+ let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
+ if let ci, showContentPreview {
+ chatItemContentPreview(chat, ci)
+ }
+ let mcIsVoice = switch mc { case .voice: true; default: false }
+ if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
+ let hasFilePreview = if case .file = mc { true } else { false }
+ chatMessagePreview(cItem, hasFilePreview)
+ } else {
+ Spacer()
+ chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
+ }
}
- let mcIsVoice = switch mc { case .voice: true; default: false }
- if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
- let hasFilePreview = if case .file = mc { true } else { false }
- chatMessagePreview(cItem, hasFilePreview)
- } else {
- Spacer()
- chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
+ .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
+ checkActiveContentPreview(chat, ci, mc)
}
+ .onChange(of: activeContentPreview) { _ in
+ checkActiveContentPreview(chat, ci, mc)
+ }
+ .onChange(of: showFullscreenGallery) { _ in
+ checkActiveContentPreview(chat, ci, mc)
+ }
+ chatStatusImage()
+ .padding(.top, dynamicChatInfoSize * 1.44)
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
- .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- .onChange(of: activeContentPreview) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- .onChange(of: showFullscreenGallery) { _ in
- checkActiveContentPreview(chat, ci, mc)
- }
- chatStatusImage()
- .padding(.top, dynamicChatInfoSize * 1.44)
- .frame(maxWidth: .infinity, alignment: .trailing)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.trailing, 8)
+
+ Spacer()
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.trailing, 8)
-
- Spacer()
+ .frame(maxHeight: .infinity)
+ }
+ .opacity(deleting ? 0.4 : 1)
+ .padding(.bottom, -8)
+
+ if deleting {
+ ProgressView()
+ .scaleEffect(2)
}
- .frame(maxHeight: .infinity)
}
- .padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
// Stop voice when deleting the chat
@@ -133,8 +141,9 @@ struct ChatPreviewView: View {
} else {
EmptyView()
}
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
switch (groupInfo.membership.memberStatus) {
+ case .memRejected: inactiveIcon()
case .memLeft: inactiveIcon()
case .memRemoved: inactiveIcon()
case .memGroupDeleted: inactiveIcon()
@@ -145,7 +154,7 @@ struct ChatPreviewView: View {
}
}
- @ViewBuilder private func inactiveIcon() -> some View {
+ private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
@@ -155,14 +164,26 @@ struct ChatPreviewView: View {
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
switch chat.chatInfo {
case let .direct(contact):
- previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil)
- case let .group(groupInfo):
- let v = previewTitle(t)
- switch (groupInfo.membership.memberStatus) {
- case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
- case .memAccepted: v.foregroundColor(theme.colors.secondary)
- default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
+ let color =
+ deleting
+ ? theme.colors.secondary
+ : (contact.nextAcceptContactRequest && !(contact.groupDirectInv?.memberRemoved ?? false)) || contact.sendMsgToConnect
+ ? theme.colors.primary
+ : !contact.sndReady
+ ? theme.colors.secondary
+ : nil
+ previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color)
+ case let .group(groupInfo, _):
+ let color = if deleting {
+ theme.colors.secondary
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memInvited: chat.chatInfo.incognito ? .indigo : theme.colors.primary
+ case .memAccepted, .memRejected: theme.colors.secondary
+ default: groupInfo.nextConnectPrepared ? theme.colors.primary : nil
+ }
}
+ previewTitle(t).foregroundColor(color)
default: previewTitle(t)
}
}
@@ -178,14 +199,17 @@ struct ChatPreviewView: View {
.kerning(-2)
}
- private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
+ private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View {
ZStack(alignment: .topTrailing) {
+ let s = chat.chatStats
+ let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 }
let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.multilineTextAlignment(.leading)
+ .if(hasSecrets, transform: hiddenSecretsView)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8)
- .padding(.trailing, hasFilePreview ? 38 : 36)
+ .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft {
@@ -200,19 +224,34 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
- unreadCountText(s.unreadCount)
- .font(userFont <= .xxxLarge ? .caption : .caption2)
- .foregroundColor(.white)
- .padding(.horizontal, dynamicSize(userFont).unreadPadding)
- .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
- .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
- .cornerRadius(dynamicSize(userFont).unreadCorner)
- } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
- Image(systemName: "speaker.slash.fill")
+ let mentionColor = mentionColor(chat)
+ HStack(alignment: .center, spacing: 2) {
+ if s.unreadMentions > 0 && s.unreadCount > 1 {
+ Text("\(MENTION_START)")
+ .font(userFont <= .xxxLarge ? .body : .callout)
+ .foregroundColor(mentionColor)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ .padding(.bottom, 1)
+ }
+ let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1
+ (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount))
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
+ .frame(height: dynamicChatInfoSize)
+ } else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all {
+ let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize
+ let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary
+ Image(systemName: ntfMode.iconFilled)
.resizable()
.scaledToFill()
- .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
- .foregroundColor(theme.colors.secondary)
+ .frame(width: iconSize, height: iconSize)
+ .foregroundColor(iconColor)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
@@ -225,11 +264,21 @@ struct ChatPreviewView: View {
}
}
- private func messageDraft(_ draft: ComposeState) -> Text {
+ private func mentionColor(_ chat: Chat) -> Color {
+ switch chat.chatInfo.chatSettings?.enableNtfs {
+ case .all: theme.colors.primary
+ case .mentions: theme.colors.primary
+ default: theme.colors.secondary
+ }
+ }
+
+ private func messageDraft(_ draft: ComposeState) -> (Text, Bool) {
let msg = draft.message
- return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
- + attachment()
- + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ let r = markdownText(msg, preview: true, mentions: draft.mentions, backgroundColor: theme.colors.background)
+ return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ + attachment()
+ + Text(AttributedString(r.string)),
+ r.hasSecrets)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
Text(Image(systemName: s)).foregroundColor(color) + textSpace
@@ -245,10 +294,11 @@ struct ChatPreviewView: View {
}
}
- func chatItemPreview(_ cItem: ChatItem) -> Text {
+ func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
- return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix())
+ let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
+ return (Text(AttributedString(r.string)), r.hasSecrets)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
@@ -274,46 +324,67 @@ struct ChatPreviewView: View {
default: return nil
}
}
-
- func prefix() -> Text {
+
+ func prefix() -> NSAttributedString? {
switch cItem.content.msgContent {
- case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red)
- default: return Text("")
+ case let .report(_, reason): reason.attrString
+ default: nil
}
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
- chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
+ let (t, hasSecrets) = messageDraft(draft)
+ chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
+ } else if cItem?.content.hasMsgContent != true, let previewText = chatPreviewInfoText() {
+ chatPreviewInfoTextLayout(previewText)
} else if let cItem = cItem {
- chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
- } else {
- switch (chat.chatInfo) {
- case let .direct(contact):
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
- chatPreviewInfoText("Tap to Connect")
- .foregroundColor(theme.colors.primary)
- } else if !contact.sndReady && contact.activeConn != nil {
- if contact.nextSendGrpInv {
- chatPreviewInfoText("send direct message")
- } else if contact.active {
- chatPreviewInfoText("connecting…")
- }
- }
- case let .group(groupInfo):
- switch (groupInfo.membership.memberStatus) {
- case .memInvited: groupInvitationPreviewText(groupInfo)
- case .memAccepted: chatPreviewInfoText("connecting…")
- default: EmptyView()
- }
- default: EmptyView()
+ let (t, hasSecrets) = chatItemPreview(cItem)
+ chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
+ }
+ }
+
+ private func chatPreviewInfoText() -> Text? {
+ switch (chat.chatInfo) {
+ case let .direct(contact):
+ if contact.isContactCard {
+ Text("Tap to Connect")
+ .foregroundColor(theme.colors.primary)
+ } else if contact.isBot && contact.nextConnectPrepared {
+ Text("Open to use bot")
+ } else if contact.sendMsgToConnect {
+ Text("Open to connect")
+ } else if contact.nextAcceptContactRequest {
+ Text("Open to accept")
+ } else if !contact.sndReady && contact.activeConn != nil && contact.active {
+ (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil
+ ? Text("contact should accept…")
+ : Text("connecting…")
+ } else {
+ nil
}
+ case let .group(groupInfo, _):
+ if groupInfo.nextConnectPrepared {
+ if groupInfo.businessChat?.chatType == .business {
+ Text("Open to connect")
+ } else {
+ Text("Open to join")
+ }
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memRejected: Text("rejected")
+ case .memInvited: groupInvitationPreviewText(groupInfo)
+ case .memAccepted: Text("connecting…")
+ case .memPendingReview, .memPendingApproval: Text("reviewed by admins")
+ default: nil
+ }
+ }
+ default: nil
}
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
- let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
@@ -335,28 +406,16 @@ struct ChatPreviewView: View {
.cornerRadius(8)
}
.onTapGesture {
- switch privacyChatListOpenLinksDefault.get() {
- case .yes: UIApplication.shared.open(preview.uri)
- case .no: ItemsModel.shared.loadOpenChat(chat.id)
- case .ask: AlertManager.shared.showAlert(
- Alert(title: Text("Open web link?"),
- message: Text(preview.uri.absoluteString),
- primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }),
- secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) })
- )
- )
- }
+ openBrowserAlert(uri: preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -371,14 +430,14 @@ struct ChatPreviewView: View {
}
- @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
+ private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> Text {
groupInfo.membership.memberIncognito
- ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
- : chatPreviewInfoText("you are invited to group")
+ ? Text("Join as \(groupInfo.membership.memberProfile.displayName)")
+ : Text("You are invited to group")
}
- @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
- Text(text)
+ private func chatPreviewInfoTextLayout(_ text: Text) -> some View {
+ text
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
@@ -401,17 +460,15 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
- case let .direct(contact):
- if contact.active && contact.activeConn != nil {
- NetworkStatusView(contact: contact, size: size)
- } else {
- incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
- }
case .group:
if progressByTimeout {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
- groupReportsIcon(size: size * 0.8)
+ flagIcon(size: size * 0.8, color: .red)
+ } else if chat.supportUnreadCount > 0 {
+ flagIcon(size: size * 0.8, color: theme.colors.primary)
+ } else if chat.chatInfo.groupInfo?.membership.memberPending ?? false {
+ flagIcon(size: size * 0.8, color: theme.colors.secondary)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -419,30 +476,6 @@ struct ChatPreviewView: View {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
}
-
- struct NetworkStatusView: View {
- @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
- @EnvironmentObject var theme: AppTheme
- @ObservedObject var networkModel = NetworkModel.shared
-
- let contact: Contact
- let size: CGFloat
-
- var body: some View {
- let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
- switch (networkModel.contactNetworkStatus(contact)) {
- case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size)
- case .error:
- Image(systemName: "exclamationmark.circle")
- .resizable()
- .scaledToFit()
- .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
- .foregroundColor(theme.colors.secondary)
- default:
- ProgressView()
- }
- }
- }
}
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
@@ -457,12 +490,12 @@ struct ChatPreviewView: View {
}
}
-@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View {
+func flagIcon(size: CGFloat, color: Color) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
- .foregroundColor(.red)
+ .foregroundColor(color)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
index 0f64b632dc..124c5ee7ba 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
@@ -14,6 +14,7 @@ struct ContactConnectionInfo: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection
+ @State private var showShortLink: Bool = true
@State private var alert: CCInfoAlert?
@State private var localAlias = ""
@State private var showIncognitoSheet = false
@@ -61,14 +62,19 @@ struct ContactConnectionInfo: View {
}
if contactConnection.initiated,
- let connReqInv = contactConnection.connReqInv {
- SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
+ let connLinkInv = contactConnection.connLinkInv {
+ SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink)
+ .id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))")
incognitoEnabled()
- shareLinkButton(connReqInv, theme.colors.secondary)
- oneTimeLinkLearnMoreButton(theme.colors.secondary)
+ shareLinkButton(connLinkInv, short: showShortLink)
+ oneTimeLinkLearnMoreButton()
} else {
incognitoEnabled()
- oneTimeLinkLearnMoreButton(theme.colors.secondary)
+ oneTimeLinkLearnMoreButton()
+ }
+ } header: {
+ if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil {
+ ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink)
}
} footer: {
sharedProfileInfo(contactConnection.incognito)
@@ -108,6 +114,7 @@ struct ContactConnectionInfo: View {
.onAppear {
localAlias = contactConnection.localAlias
}
+ .onDisappear(perform: setConnectionAlias)
}
private func setConnectionAlias() {
@@ -167,26 +174,22 @@ struct ContactConnectionInfo: View {
}
}
-private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
+private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View {
Button {
- showShareSheet(items: [simplexChatLink(connReqInvitation)])
+ showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)])
} label: {
- settingsRow("square.and.arrow.up", color: secondaryColor) {
- Text("Share 1-time link")
- }
+ Label("Share 1-time link", systemImage: "square.and.arrow.up")
}
}
-private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
+private func oneTimeLinkLearnMoreButton() -> some View {
NavigationLink {
AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
- settingsRow("info.circle", color: secondaryColor) {
- Text("Learn more")
- }
+ Label("Learn more", systemImage: "info.circle")
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
index aa802c1af9..ee7605dbd2 100644
--- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
+++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
@@ -245,7 +245,7 @@ struct ServersSummaryView: View {
}
}
- @ViewBuilder private func smpServersListView(
+ private func smpServersListView(
_ servers: [SMPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
@@ -256,7 +256,7 @@ struct ServersSummaryView: View {
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
: $0.hasSubs && !$1.hasSubs
}
- Section {
+ return Section {
ForEach(sortedServers) { server in
smpServerView(server, statsStartedAt)
}
@@ -318,14 +318,14 @@ struct ServersSummaryView: View {
return onionHosts == .require ? .indigo : .accentColor
}
- @ViewBuilder private func xftpServersListView(
+ private func xftpServersListView(
_ servers: [XFTPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil
) -> some View {
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
- Section {
+ return Section {
ForEach(sortedServers) { server in
xftpServerView(server, statsStartedAt)
}
@@ -412,7 +412,7 @@ struct SubscriptionStatusIndicatorView: View {
var hasSess: Bool
var body: some View {
- let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
+ let (color, variableValue, opacity) = subscriptionStatusInfo(
online: m.networkInfo.online,
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
@@ -431,25 +431,19 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
- @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
- online: m.networkInfo.online,
- usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
- subs: subs,
- hasSess: hasSess,
- primaryColor: theme.colors.primary
- )
- Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
+ let statusPercent = subscriptionStatusPercent(online: m.networkInfo.online, subs: subs, hasSess: hasSess)
+ let percentText: String = subs.total > 0 || hasSess ? "\(Int(floor(statusPercent * 100)))%" : "%"
+ Text(percentText)
.foregroundColor(.secondary)
.font(.caption)
}
}
-func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
+func subscriptionStatusInfo(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -457,26 +451,28 @@ func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: S
}
let activeColor: Color = usesProxy ? .indigo : primaryColor
- let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
+ let noConnColorAndPercent: (Color, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online
? noConnColorAndPercent
- : (
- subs.total == 0 && !hasSess
- ? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
- : (
- subs.ssActive == 0
- ? (
- hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
- )
- : ( // ssActive > 0
- hasSess
- ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
- : (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
- )
- )
+ : subs.total == 0 && !hasSess
+ ? (activeColor, 0, 0.33) // On freshly installed app (without chats) and on app start
+ : subs.ssActive == 0
+ ? (
+ hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive) : noConnColorAndPercent
)
+ : ( // ssActive > 0
+ hasSess
+ ? (activeColor, activeSubsRounded, subs.shareOfActive)
+ : (.orange, activeSubsRounded, subs.shareOfActive) // This would mean implementation error
+ )
+}
+
+func subscriptionStatusPercent(online: Bool, subs: SMPServerSubs, hasSess: Bool) -> Double {
+ online && (hasSess || (subs.total > 0 && subs.ssActive > 0))
+ ? subs.shareOfActive
+ : 0
}
struct SMPServerSummaryView: View {
@@ -587,7 +583,7 @@ struct SMPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
}
}
}
@@ -703,7 +699,7 @@ struct XFTPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
}
}
}
diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift
index 8811234f52..79d122eabf 100644
--- a/apps/ios/Shared/Views/ChatList/TagListView.swift
+++ b/apps/ios/Shared/Views/ChatList/TagListView.swift
@@ -61,12 +61,9 @@ struct TagListView: View {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
- message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"),
+ message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text),
actions: {[
- UIAlertAction(
- title: NSLocalizedString("Cancel", comment: "alert action"),
- style: .default
- ),
+ cancelAlertAction,
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
@@ -138,7 +135,7 @@ struct TagListView: View {
}
}
- @ViewBuilder private func radioButton(selected: Bool) -> some View {
+ private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift
index dbe10ad997..b1cd4015c6 100644
--- a/apps/ios/Shared/Views/ChatList/UserPicker.swift
+++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift
@@ -97,7 +97,7 @@ struct UserPicker: View {
}
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
- if case .active = scenePhase {
+ if case .active = scenePhase, hasChatCtrl() {
currentUser = m.currentUser?.userId
Task {
do {
@@ -124,7 +124,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
- UnreadBadge(userInfo: u).offset(x: 4, y: -4)
+ userUnreadBadge(u, theme: theme).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
@@ -171,19 +171,27 @@ struct UserPicker: View {
}
}
+@inline(__always)
+func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View {
+ UnreadBadge(
+ count: userInfo.unreadCount,
+ color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary
+ )
+}
+
struct UnreadBadge: View {
- var userInfo: UserInfo
- @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var count: Int
+ var color: Color
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
- unreadCountText(userInfo.unreadCount)
+ unreadCountText(count)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
- .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
+ .background(color)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
index 242b492e83..fcfcde2c07 100644
--- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
+++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
@@ -20,20 +20,17 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false
var body: some View {
- let contactType = chatContactType(chat)
-
Group {
switch (chat.chatInfo) {
case let .direct(contact):
- switch contactType {
- case .recent:
- recentContactNavLink(contact)
- case .chatDeleted:
- deletedChatNavLink(contact)
- case .card:
+ if contact.nextAcceptContactRequest {
+ contactWithRequestNavLink(contact)
+ } else if contact.isContactCard {
contactCardNavLink(contact)
- default:
- EmptyView()
+ } else if contact.chatDeleted {
+ deletedChatNavLink(contact)
+ } else if contact.active {
+ recentContactNavLink(contact)
}
case let .contactRequest(contactRequest):
contactRequestNavLink(contactRequest)
@@ -59,7 +56,7 @@ struct ContactListNavLink: View {
ItemsModel.shared.loadOpenChat(contact.id)
}
} label: {
- contactPreview(contact, titleColor: theme.colors.onBackground)
+ contactPreview(contact, titleColor: contact.sendMsgToConnect ? theme.colors.primary : theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
@@ -78,6 +75,67 @@ struct ContactListNavLink: View {
}
}
+ func contactWithRequestNavLink(_ contact: Contact) -> some View {
+ Button {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(contact.id)
+ }
+ } label: {
+ contactRequestPreview(color: contact.groupDirectInv?.memberRemoved == true ? theme.colors.secondary : theme.colors.primary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if let contactRequestId = contact.contactRequestId {
+ Button {
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) }
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) }
+ } label: {
+ Label("Accept incognito", systemImage: "theatermasks")
+ }
+ .tint(.indigo)
+ }
+ Button {
+ alert = SomeAlert(alert: rejectContactRequestAlert(contactRequestId), id: "rejectContactRequestAlert")
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else if let groupDirectInv = contact.groupDirectInv, !groupDirectInv.memberRemoved {
+ Button {
+ acceptMemberContactRequest(contact)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ Button {
+ showRejectMemberContactRequestAlert(contact)
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else {
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
+ }
+ }
+
func deletedChatNavLink(_ contact: Contact) -> some View {
Button {
Task {
@@ -140,9 +198,9 @@ struct ContactListNavLink: View {
}
}
- @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
+ private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
- (
+ return (
contact.verified == true
? verifiedIcon + t
: t
@@ -179,8 +237,14 @@ struct ContactListNavLink: View {
.tint(.red)
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
- Button("Use current profile") { connectContactViaAddress_(contact, false) }
- Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
+ if !contact.profileChangeProhibited {
+ Button("Use current profile") { connectContactViaAddress_(contact, false) }
+ Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
+ } else if !contact.contactConnIncognito {
+ Button("Use current profile") { connectContactViaAddress_(contact, false) }
+ } else {
+ Button("Use incognito profile") { connectContactViaAddress_(contact, true) }
+ }
}
}
@@ -188,8 +252,7 @@ struct ContactListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok {
- ItemsModel.shared.loadOpenChat(contact.id)
- DispatchQueue.main.async {
+ ItemsModel.shared.loadOpenChat(contact.id) {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
@@ -220,39 +283,43 @@ struct ContactListNavLink: View {
Button {
showContactRequestDialog = true
} label: {
- contactRequestPreview(contactRequest)
+ contactRequestPreview(color: theme.colors.primary)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary)
- Button {
- Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
- } label: {
- Label("Accept incognito", systemImage: "theatermasks")
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) }
+ } label: {
+ Label("Accept incognito", systemImage: "theatermasks")
+ }
+ .tint(.indigo)
}
- .tint(.indigo)
Button {
- alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert")
+ alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest.apiId), id: "rejectContactRequestAlert")
} label: {
Label("Reject", systemImage: "multiply")
}
.tint(.red)
}
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
- Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
- Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
+ Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } }
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } }
+ }
+ Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } }
}
}
- func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View {
+ func contactRequestPreview(color: Color) -> some View {
HStack{
- ProfileImage(imageStr: contactRequest.image, size: 30)
+ ProfileImage(imageStr: chat.chatInfo.image, size: 30)
Text(chat.chatInfo.chatViewName)
- .foregroundColor(.accentColor)
+ .foregroundColor(color)
.lineLimit(1)
Spacer()
@@ -261,7 +328,7 @@ struct ContactListNavLink: View {
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
- .foregroundColor(.accentColor)
+ .foregroundColor(color)
}
}
}
diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
index 3cd37e4930..441a164f8a 100644
--- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
@@ -173,7 +173,7 @@ struct DatabaseEncryptionView: View {
}
return true
} catch let error {
- if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
+ if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
index 6222a28fb4..02a1b87826 100644
--- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
@@ -28,7 +28,7 @@ struct DatabaseErrorView: View {
}
}
- @ViewBuilder private func databaseErrorView() -> some View {
+ private func databaseErrorView() -> some View {
VStack(alignment: .center, spacing: 20) {
switch status {
case let .errorNotADatabase(dbFile):
@@ -141,7 +141,7 @@ struct DatabaseErrorView: View {
}
private func migrationsText(_ ms: [String]) -> some View {
- (Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption))
+ (Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption))
.multilineTextAlignment(.center)
.padding(.horizontal, 25)
}
diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift
index 4c05434eb6..a7e61b3105 100644
--- a/apps/ios/Shared/Views/Database/DatabaseView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseView.swift
@@ -21,7 +21,7 @@ enum DatabaseAlert: Identifiable {
case deleteLegacyDatabase
case deleteFilesAndMedia
case setChatItemTTL(ttl: ChatItemTTL)
- case error(title: LocalizedStringKey, error: String = "")
+ case error(title: String, error: String = "")
var id: String {
switch self {
@@ -279,7 +279,7 @@ struct DatabaseView: View {
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
+ message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath])
}
@@ -456,7 +456,7 @@ struct DatabaseView: View {
}
} catch let error {
await MainActor.run {
- alert = .error(title: "Error exporting chat database", error: responseError(error))
+ alert = .error(title: NSLocalizedString("Error exporting chat database", comment: "alert title"), error: responseError(error))
progressIndicator = false
}
}
@@ -492,10 +492,10 @@ struct DatabaseView: View {
return migration
}
} catch let error {
- await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
+ await operationEnded(.error(title: NSLocalizedString("Error importing chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
}
} catch let error {
- await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
+ await operationEnded(.error(title: NSLocalizedString("Error deleting chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
}
} else {
showAlert("Error accessing database file")
@@ -513,7 +513,7 @@ struct DatabaseView: View {
await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
return true
} catch let error {
- await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
+ await DatabaseView.operationEnded(.error(title: NSLocalizedString("Error deleting database", comment: "alert title"), error: responseError(error)), $progressIndicator, $alert)
return false
}
}
@@ -522,7 +522,7 @@ struct DatabaseView: View {
if removeLegacyDatabaseAndFiles() {
legacyDatabase = false
} else {
- alert = .error(title: "Error deleting old database")
+ alert = .error(title: NSLocalizedString("Error deleting old database", comment: "alert title"))
}
}
@@ -546,7 +546,7 @@ struct DatabaseView: View {
let (title, message) = chatDeletedAlertText()
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
} else if case let .error(title, error) = dbAlert {
- showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] })
+ showAlert(title, message: error, actions: { [okAlertActionWaiting] })
} else {
alert.wrappedValue = dbAlert
cont.resume()
@@ -567,7 +567,7 @@ struct DatabaseView: View {
}
} catch {
await MainActor.run {
- alert = .error(title: "Error changing setting", error: responseError(error))
+ alert = .error(title: NSLocalizedString("Error changing setting", comment: "alert title"), error: responseError(error))
chatItemTTL = currentChatItemTTL
afterSetCiTTL()
}
diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift
index 1e334367e8..17fe95a058 100644
--- a/apps/ios/Shared/Views/Helpers/AppSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift
@@ -33,7 +33,7 @@ extension View {
func appSheet(
isPresented: Binding,
onDismiss: (() -> Void)? = nil,
- content: @escaping () -> Content
+ @ViewBuilder content: @escaping () -> Content
) -> some View where Content: View {
sheet(isPresented: isPresented, onDismiss: onDismiss) {
content().modifier(PrivacySensitive())
@@ -43,7 +43,7 @@ extension View {
func appSheet(
item: Binding,
onDismiss: (() -> Void)? = nil,
- content: @escaping (T) -> Content
+ @ViewBuilder content: @escaping (T) -> Content
) -> some View where T: Identifiable, Content: View {
sheet(item: item, onDismiss: onDismiss) { it in
content(it).modifier(PrivacySensitive())
diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
index 9aa6ac86cf..980308f13c 100644
--- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
@@ -76,7 +76,7 @@ struct ChatTailPadding: ViewModifier {
}
}
-private let msgRectMaxRadius: Double = 18
+let msgRectMaxRadius: Double = 18
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
private let msgTailWidth: Double = 9
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
diff --git a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
index 09aae1cb15..edb10ef87d 100644
--- a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
@@ -220,6 +220,35 @@ struct DropdownCustomTimePicker: View {
}
}
+struct WrappedPicker: View {
+ var selection: Binding
+ @ViewBuilder var content: () -> Content
+ @ViewBuilder var label: () -> Label
+
+ init(_ title: LocalizedStringKey, selection: Binding, @ViewBuilder content: @escaping () -> Content) where Label == Text {
+ self.selection = selection
+ self.content = content
+ self.label = { Text(title) }
+ }
+
+ init(selection: Binding, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> Label) {
+ self.selection = selection
+ self.content = content
+ self.label = label
+ }
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ label()
+ Spacer()
+ Picker(selection: selection, content: content) {
+ EmptyView()
+ }
+ .frame(height: 36)
+ }
+ }
+}
+
struct CustomTimePicker_Previews: PreviewProvider {
static var previews: some View {
CustomTimePicker(
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
index 3eedd56441..9c2916880c 100644
--- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -27,8 +27,13 @@ struct ProfileImage: View {
Image(systemName: iconName)
.resizable()
.foregroundColor(c)
+ .scaledToFit()
.frame(width: size, height: size)
- .background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear))
+ .background(
+ Circle()
+ .fill(backgroundColor != nil ? backgroundColor! : .clear)
+ .frame(width: size - 2, height: size - 2) // less than size of Image to avoid slightly visible border
+ )
}
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index b8de0e4ceb..86a5dc7aaa 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -65,6 +65,252 @@ func showAlert(
}
}
+func showSheet(
+ _ title: String?,
+ message: String? = nil,
+ actions: () -> [UIAlertAction] = { [okAlertAction] },
+ sourceView: UIView? = nil // For iPad support
+) {
+ if let topController = getTopViewController() {
+ let sheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
+ for action in actions() { sheet.addAction(action) }
+
+ // Required for iPad: Configure popover presentation
+ if let popover = sheet.popoverPresentationController {
+ popover.sourceView = sourceView ?? topController.view
+ popover.sourceRect = sourceView?.bounds ?? CGRect(x: topController.view.bounds.midX, y: topController.view.bounds.midY, width: 0, height: 0)
+ popover.permittedArrowDirections = []
+ }
+
+ topController.present(sheet, animated: true)
+ }
+}
+
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
+
+let alertProfileImageSize: CGFloat = 103
+
+let alertWidth: CGFloat = 270
+
+let alertButtonHeight: CGFloat = 44
+
+class OpenChatAlertViewController: UIViewController {
+ private let profileName: String
+ private let profileFullName: String
+ private let profileImage: UIView
+ private let cancelTitle: String
+ private let confirmTitle: String
+ private let onCancel: () -> Void
+ private let onConfirm: () -> Void
+
+ init(
+ profileName: String,
+ profileFullName: String,
+ profileImage: UIView,
+ cancelTitle: String = "Cancel",
+ confirmTitle: String = "Open",
+ onCancel: @escaping () -> Void,
+ onConfirm: @escaping () -> Void
+ ) {
+ self.profileName = profileName
+ self.profileFullName = profileFullName
+ self.profileImage = profileImage
+ self.cancelTitle = cancelTitle
+ self.confirmTitle = confirmTitle
+ self.onCancel = onCancel
+ self.onConfirm = onConfirm
+ super.init(nibName: nil, bundle: nil)
+
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.3)
+
+ // Container view
+ let containerView = UIView()
+ containerView.backgroundColor = .systemBackground
+ containerView.layer.cornerRadius = 12
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(containerView)
+
+ // Profile image sizing
+ profileImage.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ profileImage.widthAnchor.constraint(equalToConstant: alertProfileImageSize),
+ profileImage.heightAnchor.constraint(equalToConstant: alertProfileImageSize)
+ ])
+
+ // Name label
+ let nameLabel = UILabel()
+ nameLabel.text = profileName
+ nameLabel.font = UIFont.preferredFont(forTextStyle: .headline)
+ nameLabel.textColor = .label
+ nameLabel.numberOfLines = 2
+ nameLabel.textAlignment = .center
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ var profileViews = [profileImage, nameLabel]
+
+ // Full name label
+ if !profileFullName.isEmpty && profileFullName != profileName {
+ let fullNameLabel = UILabel()
+ fullNameLabel.text = profileFullName
+ fullNameLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
+ fullNameLabel.textColor = .label
+ fullNameLabel.numberOfLines = 2
+ fullNameLabel.textAlignment = .center
+ fullNameLabel.translatesAutoresizingMaskIntoConstraints = false
+ profileViews.append(fullNameLabel)
+ }
+
+ // Horizontal stack for image + name
+ let stack = UIStackView(arrangedSubviews: profileViews)
+ stack.axis = .vertical
+ stack.spacing = 12
+ stack.alignment = .center
+ stack.translatesAutoresizingMaskIntoConstraints = false
+
+ let topRowContainer = UIView()
+ topRowContainer.translatesAutoresizingMaskIntoConstraints = false
+ topRowContainer.addSubview(stack)
+
+ NSLayoutConstraint.activate([
+ stack.topAnchor.constraint(equalTo: topRowContainer.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: topRowContainer.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: topRowContainer.leadingAnchor, constant: 20),
+ stack.trailingAnchor.constraint(equalTo: topRowContainer.trailingAnchor, constant: -20)
+ ])
+
+ // Buttons
+ let cancelButton = UIButton(type: .system)
+ cancelButton.setTitle(cancelTitle, for: .normal)
+ let bodyDescr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
+ cancelButton.titleLabel?.font = UIFont(descriptor: bodyDescr.withSymbolicTraits(.traitBold) ?? bodyDescr, size: 0)
+ cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+
+ let confirmButton = UIButton(type: .system)
+ confirmButton.setTitle(confirmTitle, for: .normal)
+ confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
+ confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+
+ let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2
+
+ // Button stack with equal width buttons
+ let buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton])
+ buttonStack.axis = verticalButtons ? .vertical : .horizontal
+ buttonStack.distribution = .fillEqually
+ buttonStack.spacing = 0 // no spacing, use divider instead
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true
+
+ // Vertical stack containing hStack and buttonStack
+ let vStack = UIStackView(arrangedSubviews: [topRowContainer, buttonStack])
+ vStack.axis = .vertical
+ vStack.spacing = 16
+ vStack.alignment = .fill // important: buttons stretch full width
+ vStack.translatesAutoresizingMaskIntoConstraints = false
+
+ containerView.addSubview(vStack)
+
+ // Add horizontal divider above buttons
+ let horizontalDivider = UIView()
+ horizontalDivider.backgroundColor = UIColor.separator
+ horizontalDivider.translatesAutoresizingMaskIntoConstraints = false
+ containerView.addSubview(horizontalDivider)
+
+ // Add divider between buttons
+ let buttonDivider = UIView()
+ buttonDivider.backgroundColor = UIColor.separator
+ buttonDivider.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.addSubview(buttonDivider)
+
+ // Constraints
+ let buttonDividerConstraints = if verticalButtons {
+ [
+ buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
+ buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ } else {
+ [
+ buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor),
+ buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor),
+ buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ }
+
+ NSLayoutConstraint.activate([
+ // Container view centering and fixed width
+ containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ containerView.widthAnchor.constraint(equalToConstant: alertWidth),
+
+ // Vertical stack padding inside containerView
+ vStack.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20),
+ vStack.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0),
+ vStack.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0),
+ vStack.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0),
+
+ // Center hStack horizontally inside vStack's padded width
+ stack.centerXAnchor.constraint(equalTo: vStack.centerXAnchor),
+
+ // Horizontal divider above buttons
+ horizontalDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ horizontalDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ horizontalDivider.bottomAnchor.constraint(equalTo: buttonStack.topAnchor),
+ horizontalDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ] + buttonDividerConstraints)
+ }
+
+ @objc private func cancelTapped() {
+ dismiss(animated: true) {
+ self.onCancel()
+ }
+ }
+
+ @objc private func confirmTapped() {
+ dismiss(animated: true) {
+ self.onConfirm()
+ }
+ }
+}
+
+
+func showOpenChatAlert(
+ profileName: String,
+ profileFullName: String,
+ profileImage: Content,
+ theme: AppTheme,
+ cancelTitle: String = "Cancel",
+ confirmTitle: String = "Open",
+ onCancel: @escaping () -> Void = {},
+ onConfirm: @escaping () -> Void
+) {
+ let themedView = profileImage.environmentObject(theme)
+ let hostingController = UIHostingController(rootView: themedView)
+ let hostedView = hostingController.view!
+ hostedView.backgroundColor = .clear
+
+ if let topVC = getTopViewController() {
+ let alertVC = OpenChatAlertViewController(
+ profileName: profileName,
+ profileFullName: profileFullName,
+ profileImage: hostedView,
+ cancelTitle: cancelTitle,
+ confirmTitle: confirmTitle,
+ onCancel: onCancel,
+ onConfirm: onConfirm
+ )
+ topVC.present(alertVC, animated: true)
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
index c790b9cff2..85ef85c611 100644
--- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
+++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift
@@ -9,6 +9,7 @@
import SwiftUI
extension View {
+ @inline(__always)
@ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
@@ -36,9 +37,9 @@ struct PrivacyBlur: ViewModifier {
.overlay {
if (blurred && enabled) {
Color.clear.contentShape(Rectangle())
- .onTapGesture {
+ .simultaneousGesture(TapGesture().onEnded {
blurred = false
- }
+ })
}
}
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
index 27bb95b599..c21ff9be8b 100644
--- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
+++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
@@ -65,6 +65,9 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
+ ItemsModel.shared.chatState.clear()
+ ChatModel.shared.secondaryIM?.reversedChatItems = []
+ ChatModel.shared.secondaryIM?.chatState.clear()
m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
index 609943bcb6..4a6f8e7549 100644
--- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
+++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
@@ -28,7 +28,7 @@ struct PasscodeEntry: View {
}
}
- @ViewBuilder private func passwordView() -> some View {
+ private func passwordView() -> some View {
Text(
password == ""
? " "
diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
index eb8df5fb04..0af8fa7ad8 100644
--- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
@@ -177,7 +177,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
+ message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) }
}
@@ -520,15 +520,15 @@ struct MigrateFromDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run {
switch msg {
- case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize):
+ case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)):
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
}
- case .sndFileRedirectStartXFTP:
+ case .result(.sndFileRedirectStartXFTP):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkCreation
}
- case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
+ case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)):
let cfg = getNetCfg()
let proxy: NetworkProxy? = if cfg.socksProxy == nil {
nil
@@ -546,7 +546,7 @@ struct MigrateFromDevice: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl)
}
- case .sndFileError:
+ case .result(.sndFileError):
alert = .error(title: "Upload failed", error: "Check your internet connection and try again")
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
default:
@@ -691,7 +691,7 @@ private struct PassphraseConfirmationView: View {
migrationState = .uploadConfirmation
}
} catch let error {
- if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
+ if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
@@ -733,11 +733,11 @@ func chatStoppedView() -> some View {
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let databaseUrl: URL
- let processReceivedMsg: (ChatResponse) async -> Void
+ let processReceivedMsg: (APIResult) async -> Void
private var receiveLoop: Task?
private var receiveMessages = true
- init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
+ init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) {
self.ctrl = ctrl
self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg
@@ -752,9 +752,9 @@ private class MigrationChatReceiver {
func receiveMsgLoop() async {
// TODO use function that has timeout
- if let msg = await chatRecvMsg(ctrl) {
+ if let msg: APIResult = await chatRecvMsg(ctrl) {
Task {
- await TerminalItems.shared.add(.resp(.now, msg))
+ await TerminalItems.shared.addResult(msg)
}
logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg)
diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
index 2d83cdc7c8..93fe19cf33 100644
--- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
@@ -496,10 +496,10 @@ struct MigrateToDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run {
switch msg {
- case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
+ case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)):
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
- case .rcvStandaloneFileComplete:
+ case .result(.rcvStandaloneFileComplete):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// User closed the whole screen before new state was saved
if migrationState == nil {
@@ -509,10 +509,10 @@ struct MigrateToDevice: View {
MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
}
}
- case .rcvFileError:
+ case .result(.rcvFileError):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
- case .chatError(_, .error(.noRcvFileUser)):
+ case .error(.error(.noRcvFileUser)):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
default:
@@ -539,7 +539,7 @@ struct MigrateToDevice: View {
chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running
- try await apiStopChat()
+ try await stopChatAsync()
}
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@@ -623,7 +623,7 @@ struct MigrateToDevice: View {
AlertManager.shared.showAlert(
Alert(
title: Text("Error migrating settings"),
- message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
+ message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error)))
)
}
hideView()
@@ -632,6 +632,8 @@ struct MigrateToDevice: View {
private func hideView() {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
+ m.migrationState = nil
+ MigrationToDeviceState.save(nil)
dismiss()
}
@@ -749,11 +751,11 @@ private func progressView() -> some View {
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let databaseUrl: URL
- let processReceivedMsg: (ChatResponse) async -> Void
+ let processReceivedMsg: (APIResult) async -> Void
private var receiveLoop: Task?
private var receiveMessages = true
- init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
+ init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) {
self.ctrl = ctrl
self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg
@@ -770,7 +772,7 @@ private class MigrationChatReceiver {
// TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) {
Task {
- await TerminalItems.shared.add(.resp(.now, msg))
+ await TerminalItems.shared.addResult(msg)
}
logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg)
diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
index 0c7f6136ff..901b2deeab 100644
--- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
@@ -23,7 +23,7 @@ struct AddGroupView: View {
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
@State private var showInvalidNameAlert = false
- @State private var groupLink: String?
+ @State private var groupLink: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View {
@@ -104,7 +104,9 @@ struct AddGroupView: View {
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
- .onTapGesture(perform: hideKeyboard)
+ .onTapGesture {
+ focusDisplayName = false
+ }
}
}
.onAppear() {
@@ -161,7 +163,8 @@ struct AddGroupView: View {
} else {
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
- textField("Enter group name…", text: $profile.displayName)
+ TextField("Enter group name…", text: $profile.displayName)
+ .padding(.leading, 36)
.focused($focusDisplayName)
.submitLabel(.continue)
.onSubmit {
@@ -170,11 +173,6 @@ struct AddGroupView: View {
}
}
- func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View {
- TextField(placeholder, text: text)
- .padding(.leading, 36)
- }
-
func sharedGroupProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
@@ -185,19 +183,15 @@ struct AddGroupView: View {
}
func createGroup() {
- hideKeyboard()
+ focusDisplayName = false
do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task {
- let groupMembers = await apiListMembers(gInfo.groupId)
- await MainActor.run {
- m.groupMembers = groupMembers.map { GMember.init($0) }
- m.populateGroupMembersIndexes()
- }
+ await m.loadGroupMembers(gInfo)
}
- let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
+ let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: [])
m.addChat(c)
withAnimation {
groupInfo = gInfo
@@ -221,6 +215,8 @@ struct AddGroupView: View {
}
}
+// Using this method may freeze the app in some cases, so it should be avoided when possible, especially when combined with .focussed modifier.
+// It also must only be called from background thread.
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
index 39656c1534..2e3119a8b8 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
@@ -9,10 +9,6 @@
import SwiftUI
import SimpleXChat
-enum ContactType: Int {
- case card, request, recent, chatDeleted, unlisted
-}
-
struct NewChatMenuButton: View {
// do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16
// @EnvironmentObject var chatModel: ChatModel
@@ -20,7 +16,8 @@ struct NewChatMenuButton: View {
@State private var alert: SomeAlert? = nil
var body: some View {
- Button {
+ Button {
+ ConnectProgressManager.shared.cancelConnectProgress()
showNewChatSheet = true
} label: {
Image(systemName: "square.and.pencil")
@@ -42,7 +39,6 @@ private var indent: CGFloat = 36
struct NewChatSheet: View {
@EnvironmentObject var theme: AppTheme
- @State private var baseContactTypes: [ContactType] = [.card, .request, .recent]
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@@ -60,7 +56,7 @@ struct NewChatSheet: View {
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
var body: some View {
- let showArchive = !filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty
+ let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true }
let v = NavigationView {
viewBody(showArchive)
.navigationTitle("New message")
@@ -70,6 +66,8 @@ struct NewChatSheet: View {
.alert(item: $alert) { a in
return a.alert
}
+ }.onDisappear {
+ ConnectProgressManager.shared.cancelConnectProgress()
}
if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500
@@ -85,7 +83,7 @@ struct NewChatSheet: View {
}
}
- @ViewBuilder private func viewBody(_ showArchive: Bool) -> some View {
+ private func viewBody(_ showArchive: Bool) -> some View {
List {
HStack {
ContactsListSearchBar(
@@ -125,7 +123,7 @@ struct NewChatSheet: View {
}
NavigationLink {
AddGroupView()
- .navigationTitle("Create secret group")
+ .navigationTitle("Create group")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
@@ -145,7 +143,7 @@ struct NewChatSheet: View {
}
ContactsList(
- baseContactTypes: $baseContactTypes,
+ chatPredicate: contactListChatPredicate,
searchMode: $searchMode,
searchText: $searchText,
header: "Your Contacts",
@@ -156,7 +154,15 @@ struct NewChatSheet: View {
)
}
}
-
+
+ private func contactListChatPredicate(_ chat: Chat, _ withSearch: Bool) -> Bool {
+ switch chat.chatInfo {
+ case .contactRequest: true
+ case let .direct(contact): contact.isContactCard || contact.active || (contact.chatDeleted && withSearch)
+ default: false
+ }
+ }
+
/// Extends label's tap area to match `.insetGrouped` list row insets
private func navigateOnTap(_ label: L, setActive: @escaping () -> Void) -> some View {
label
@@ -186,35 +192,24 @@ struct NewChatSheet: View {
}
}
-func chatContactType(_ chat: Chat) -> ContactType {
+func chatOrderRank(_ chat: Chat) -> Int {
switch chat.chatInfo {
- case .contactRequest:
- return .request
+ case .contactRequest: 4
case let .direct(contact):
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
- return .card
- } else if contact.chatDeleted {
- return .chatDeleted
- } else if contact.contactStatus == .active {
- return .recent
- } else {
- return .unlisted
- }
- default:
- return .unlisted
- }
-}
-
-private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
- return chats.filter { chat in
- contactTypes.contains(chatContactType(chat))
+ contact.isContactCard ? 5
+ : contact.nextAcceptContactRequest ? 4
+ : contact.nextConnectPrepared ? 3
+ : contact.active ? 2
+ : contact.chatDeleted ? 1
+ : 0
+ default: 0
}
}
struct ContactsList: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
- @Binding var baseContactTypes: [ContactType]
+ var chatPredicate: (Chat, Bool) -> Bool // (chat, search) -> show
@Binding var searchMode: Bool
@Binding var searchText: String
var header: String? = nil
@@ -225,8 +220,7 @@ struct ContactsList: View {
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
- let contactTypes = contactTypesSearchTargets(baseContactTypes: baseContactTypes, searchEmpty: searchText.isEmpty)
- let contactChats = filterContactTypes(chats: chatModel.chats, contactTypes: contactTypes)
+ let contactChats = chatModel.chats.filter { chat in chatPredicate(chat, !searchText.isEmpty) }
let filteredContactChats = filteredContactChats(
showUnreadAndFavorites: showUnreadAndFavorites,
searchShowingSimplexLink: searchShowingSimplexLink,
@@ -258,7 +252,7 @@ struct ContactsList: View {
}
}
- @ViewBuilder private func noResultSection(text: String) -> some View {
+ private func noResultSection(text: String) -> some View {
Section {
Text(text)
.foregroundColor(theme.colors.secondary)
@@ -269,26 +263,11 @@ struct ContactsList: View {
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0))
}
-
- private func contactTypesSearchTargets(baseContactTypes: [ContactType], searchEmpty: Bool) -> [ContactType] {
- if baseContactTypes.contains(.chatDeleted) || searchEmpty {
- return baseContactTypes
- } else {
- return baseContactTypes + [.chatDeleted]
- }
- }
-
- private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
- let chat1Type = chatContactType(chat1)
- let chat2Type = chatContactType(chat2)
- if chat1Type.rawValue < chat2Type.rawValue {
- return true
- } else if chat1Type.rawValue > chat2Type.rawValue {
- return false
- } else {
- return chat2.chatInfo.chatTs < chat1.chatInfo.chatTs
- }
+ private func chatComparator(chat1: Chat, chat2: Chat) -> Bool {
+ let r1 = chatOrderRank(chat1)
+ let r2 = chatOrderRank(chat2)
+ return r1 > r2 ? true : r1 < r2 ? false : chat1.chatInfo.chatTs > chat2.chatInfo.chatTs
}
private func filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Bool) -> Bool {
@@ -333,12 +312,13 @@ struct ContactsList: View {
}
}
- return filteredChats.sorted(by: chatsByTypeComparator)
+ return filteredChats.sorted(by: chatComparator)
}
}
struct ContactsListSearchBar: View {
@EnvironmentObject var m: ChatModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@EnvironmentObject var theme: AppTheme
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@@ -346,8 +326,6 @@ struct ContactsListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
- @State private var alert: PlanAndConnectAlert?
- @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
@@ -364,6 +342,9 @@ struct ContactsListSearchBar: View {
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.resizable()
@@ -400,7 +381,7 @@ struct ContactsListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
- if case let .simplexLink(linkType, _, smpHosts) = link.format {
+ if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -410,18 +391,14 @@ struct ContactsListSearchBar: View {
} else {
if t != "" { // if some other text is pasted, enter search mode
searchFocussed = true
+ } else {
+ connectProgressManager.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
- .alert(item: $alert) { a in
- planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
- }
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
- }
}
private func toggleFilterButton() -> some View {
@@ -442,10 +419,12 @@ struct ContactsListSearchBar: View {
private func connect(_ link: String) {
planAndConnect(
link,
- showAlert: { alert = $0 },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: true,
- incognito: nil,
+ cleanup: {
+ searchText = ""
+ searchFocussed = false
+ },
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id }
)
}
@@ -453,7 +432,6 @@ struct ContactsListSearchBar: View {
struct DeletedChats: View {
- @State private var baseContactTypes: [ContactType] = [.chatDeleted]
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@State private var searchText = ""
@@ -475,7 +453,7 @@ struct DeletedChats: View {
.frame(maxWidth: .infinity)
ContactsList(
- baseContactTypes: $baseContactTypes,
+ chatPredicate: { chat, _ in chat.chatInfo.contact?.chatDeleted == true },
searchMode: $searchMode,
searchText: $searchText,
searchFocussed: $searchFocussed,
diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift
index 6e898f4cdf..3de1fdb972 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatView.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift
@@ -29,11 +29,9 @@ struct SomeSheet: Identifiable {
}
private enum NewChatViewAlert: Identifiable {
- case planAndConnectAlert(alert: PlanAndConnectAlert)
case newChatSomeAlert(alert: SomeAlert)
var id: String {
switch self {
- case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)"
}
}
@@ -81,7 +79,8 @@ struct NewChatView: View {
@State var selection: NewChatOption
@State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false
- @State private var connReqInvitation: String = ""
+ @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil)
+ @State private var showShortLink = true
@State private var creatingConnReq = false
@State var choosingProfile = false
@State private var pastedLink: String = ""
@@ -164,8 +163,6 @@ struct NewChatView: View {
}
.alert(item: $alert) { a in
switch(a) {
- case let .planAndConnectAlert(alert):
- return planAndConnectAlert(alert, dismiss: true, cleanup: { pastedLink = "" })
case let .newChatSomeAlert(a):
return a.alert
}
@@ -174,11 +171,12 @@ struct NewChatView: View {
private func prepareAndInviteView() -> some View {
ZStack { // ZStack is needed for views to not make transitions between each other
- if connReqInvitation != "" {
+ if connLinkInvitation.connFullLink != "" {
InviteView(
invitationUsed: $invitationUsed,
contactConnection: $contactConnection,
- connReqInvitation: $connReqInvitation,
+ connLinkInvitation: $connLinkInvitation,
+ showShortLink: $showShortLink,
choosingProfile: $choosingProfile
)
} else if creatingConnReq {
@@ -190,16 +188,16 @@ struct NewChatView: View {
}
private func createInvitation() {
- if connReqInvitation == "" && contactConnection == nil && !creatingConnReq {
+ if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq {
creatingConnReq = true
Task {
_ = try? await Task.sleep(nanoseconds: 250_000000)
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
- if let (connReq, pcc) = r {
+ if let (connLink, pcc) = r {
await MainActor.run {
m.updateContactConnection(pcc)
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
- connReqInvitation = connReq
+ connLinkInvitation = connLink
contactConnection = pcc
}
} else {
@@ -243,7 +241,8 @@ private struct InviteView: View {
@EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection?
- @Binding var connReqInvitation: String
+ @Binding var connLinkInvitation: CreatedConnLink
+ @Binding var showShortLink: Bool
@Binding var choosingProfile: Bool
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@@ -261,7 +260,7 @@ private struct InviteView: View {
NavigationLink {
ActiveProfilePicker(
contactConnection: $contactConnection,
- connReqInvitation: $connReqInvitation,
+ connLinkInvitation: $connLinkInvitation,
incognitoEnabled: $incognitoDefault,
choosingProfile: $choosingProfile,
selectedProfile: selectedProfile
@@ -296,7 +295,7 @@ private struct InviteView: View {
private func shareLinkView() -> some View {
HStack {
- let link = simplexChatLink(connReqInvitation)
+ let link = connLinkInvitation.simplexChatUri(short: showShortLink)
linkTextView(link)
Button {
showShareSheet(items: [link])
@@ -310,9 +309,9 @@ private struct InviteView: View {
}
private func qrCodeView() -> some View {
- Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
- SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
- .id("simplex-qrcode-view-for-\(connReqInvitation)")
+ Section {
+ SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed)
+ .id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))")
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -322,6 +321,8 @@ private struct InviteView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ } header: {
+ ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
}
}
@@ -343,7 +344,7 @@ private struct ActiveProfilePicker: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contactConnection: PendingContactConnection?
- @Binding var connReqInvitation: String
+ @Binding var connLinkInvitation: CreatedConnLink
@Binding var incognitoEnabled: Bool
@Binding var choosingProfile: Bool
@State private var alert: SomeAlert?
@@ -366,7 +367,6 @@ private struct ActiveProfilePicker: View {
.onAppear {
profiles = chatModel.users
.map { $0.user }
- .sorted { u, _ in u.activeUser }
}
.onChange(of: incognitoEnabled) { incognito in
if profileSwitchStatus != .switchingIncognito {
@@ -417,15 +417,14 @@ private struct ActiveProfilePicker: View {
do {
if let contactConn = contactConnection,
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
-
await MainActor.run {
contactConnection = conn
- connReqInvitation = conn.connReqInv ?? ""
+ connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil)
incognitoEnabled = false
chatModel.updateContactConnection(conn)
}
do {
- try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
+ try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil)
await MainActor.run {
profileSwitchStatus = .idle
dismiss()
@@ -436,7 +435,7 @@ private struct ActiveProfilePicker: View {
alert = SomeAlert(
alert: Alert(
title: Text("Error switching profile"),
- message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
+ message: Text("Your connection was moved to \(profile.chatViewName) but an error happened when switching profile.")
),
id: "switchingProfileError"
)
@@ -475,8 +474,8 @@ private struct ActiveProfilePicker: View {
IncognitoHelp()
}
}
-
-
+
+
@ViewBuilder private func viewBody() -> some View {
profilePicker()
.allowsHitTesting(!switchingProfileByTimeout)
@@ -489,11 +488,11 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
private func filteredProfiles() -> [User] {
let s = trimmedSearchTextOrPassword
let lower = s.localizedLowercase
-
+
return profiles.filter { u in
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
return true
@@ -501,8 +500,8 @@ private struct ActiveProfilePicker: View {
return correctPassword(u, s)
}
}
-
- @ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
+
+ private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if selectedProfile == user && incognitoEnabled {
incognitoEnabled = false
@@ -527,7 +526,7 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
if !incognitoEnabled {
@@ -553,14 +552,16 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
List {
let filteredProfiles = filteredProfiles()
let activeProfile = filteredProfiles.first { u in u.activeUser }
-
+
if let selectedProfile = activeProfile {
- let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
-
+ let otherProfiles = filteredProfiles
+ .filter { u in u.userId != activeProfile?.userId }
+ .sorted(using: KeyPathComparator(\.activeOrder, order: .reverse))
+
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
@@ -568,7 +569,7 @@ private struct ActiveProfilePicker: View {
profilerPickerUserOption(selectedProfile)
incognitoOption
}
-
+
ForEach(otherProfiles) { p in
profilerPickerUserOption(p)
}
@@ -584,12 +585,13 @@ private struct ActiveProfilePicker: View {
}
private struct ConnectView: View {
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var showQRCodeScanner: Bool
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
- @State private var sheet: PlanAndConnectActionSheet?
+ @State var scannerPaused: Bool = false
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
@@ -598,39 +600,49 @@ private struct ConnectView: View {
pasteLinkView()
}
Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) {
- ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
+ ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode)
}
}
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
+ .onDisappear {
+ connectProgressManager.cancelConnectProgress()
}
}
@ViewBuilder private func pasteLinkView() -> some View {
if pastedLink == "" {
- Button {
- if let str = UIPasteboard.general.string {
- if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
- pastedLink = link.text
- // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
- // https://github.com/twostraws/CodeScanner/issues/121
- // No known tricks worked (changing view ID, wrapping it in another view, etc.)
- // showQRCodeScanner = false
- connect(pastedLink)
- } else {
- alert = .newChatSomeAlert(alert: SomeAlert(
- alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
- id: "pasteLinkView: code is not a SimpleX link"
- ))
+ ZStack(alignment: .trailing) {
+ Button {
+ if let str = UIPasteboard.general.string {
+ if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
+ pastedLink = link.text
+ // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
+ // https://github.com/twostraws/CodeScanner/issues/121
+ // No known tricks worked (changing view ID, wrapping it in another view, etc.)
+ // showQRCodeScanner = false
+ connect(pastedLink)
+ } else {
+ alert = .newChatSomeAlert(alert: SomeAlert(
+ alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
+ id: "pasteLinkView: code is not a SimpleX link"
+ ))
+ }
}
+ } label: {
+ Text("Tap to paste link")
+ }
+ .disabled(!pasteboardHasStrings)
+ .frame(maxWidth: .infinity, alignment: .center)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
}
- } label: {
- Text("Tap to paste link")
}
- .disabled(!pasteboardHasStrings)
- .frame(maxWidth: .infinity, alignment: .center)
} else {
- linkTextView(pastedLink)
+ HStack {
+ linkTextView(pastedLink)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
+ }
}
}
@@ -656,18 +668,22 @@ private struct ConnectView: View {
}
private func connect(_ link: String) {
+ scannerPaused = true
planAndConnect(
link,
- showAlert: { alert = .planAndConnectAlert(alert: $0) },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: true,
- incognito: nil
+ cleanup: {
+ pastedLink = ""
+ scannerPaused = false
+ }
)
}
}
struct ScannerInView: View {
@Binding var showQRCodeScanner: Bool
+ var scannerPaused: Binding? = nil
let processQRCode: (_ resp: Result) -> Void
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
var scanMode: ScanMode = .continuous
@@ -675,7 +691,7 @@ struct ScannerInView: View {
var body: some View {
Group {
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
- CodeScannerView(codeTypes: [.qr], scanMode: scanMode, completion: processQRCode)
+ CodeScannerView(codeTypes: [.qr], scanMode: scanMode, isPaused: scannerPaused?.wrappedValue ?? false, completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
.listRowBackground(Color.clear)
@@ -835,311 +851,570 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
)
}
-enum PlanAndConnectAlert: Identifiable {
- case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
- case invitationLinkConnecting(connectionLink: String)
- case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
- case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
- case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
- case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
- case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
-
- var id: String {
- switch self {
- case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
- case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
- case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
- case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
- case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
- case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
- case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
- }
- }
+private func showInvitationLinkConnectingAlert(cleanup: (() -> Void)?) {
+ showAlert(
+ NSLocalizedString("Already connecting!", comment: "new chat sheet title"),
+ message: NSLocalizedString("You are already connecting via this one-time link!", comment: "new chat sheet message"),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
+ )
}
-func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (() -> Void)? = nil) -> Alert {
- switch alert {
- case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Connect to yourself?"),
- message: Text("This is your own one-time link!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case .invitationLinkConnecting:
- return Alert(
- title: Text("Already connecting!"),
- message: Text("You are already connecting via this one-time link!"),
- dismissButton: .default(Text("OK")) { cleanup?() }
- )
- case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Connect to yourself?"),
- message: Text("This is your own SimpleX address!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Repeat connection request?"),
- message: Text("You have already requested connection via this address!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Join group?"),
- message: Text("You will connect to all group members."),
- primaryButton: .default(
- Text(incognito ? "Join incognito" : "Join"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Repeat join request?"),
- message: Text("You are already joining the group via this link!"),
- primaryButton: .destructive(
- Text(incognito ? "Join incognito" : "Join"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConnecting(_, groupInfo):
- if let groupInfo = groupInfo {
- return groupInfo.businessChat == nil
- ? Alert(
- title: Text("Group already exists!"),
- message: Text("You are already joining the group \(groupInfo.displayName)."),
- dismissButton: .default(Text("OK")) { cleanup?() }
- )
- : Alert(
- title: Text("Chat already exists!"),
- message: Text("You are already connecting to \(groupInfo.displayName)."),
- dismissButton: .default(Text("OK")) { cleanup?() }
+private func showGroupLinkConnectingAlert(groupInfo: GroupInfo?, cleanup: (() -> Void)?) {
+ if let groupInfo = groupInfo {
+ if groupInfo.businessChat == nil {
+ showAlert(
+ NSLocalizedString("Group already exists!", comment: "new chat sheet title"),
+ message:
+ String.localizedStringWithFormat(
+ NSLocalizedString("You are already joining the group %@.", comment: "new chat sheet message"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
)
} else {
- return Alert(
- title: Text("Already joining the group!"),
- message: Text("You are already joining the group via this link."),
- dismissButton: .default(Text("OK")) { cleanup?() }
+ showAlert(
+ NSLocalizedString("Chat already exists!", comment: "new chat sheet title"),
+ message:
+ String.localizedStringWithFormat(
+ NSLocalizedString("You are already connecting to %@.", comment: "new chat sheet message"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
)
}
+ } else {
+ showAlert(
+ NSLocalizedString("Already joining the group!", comment: "new chat sheet title"),
+ message: NSLocalizedString("You are already joining the group via this link.", comment: "new chat sheet message"),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
+ )
}
}
-enum PlanAndConnectActionSheet: Identifiable {
- case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
- case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
- case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
- case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
-
- var id: String {
- switch self {
- case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
- case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
- case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
- case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
+private func okCleanupAlertAction(cleanup: (() -> Void)?) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Ok", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
}
- }
+ )
}
-func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool, cleanup: (() -> Void)? = nil) -> ActionSheet {
- switch sheet {
- case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
- return ActionSheet(
- title: Text(title),
- buttons: [
- .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
- return ActionSheet(
- title: Text(title),
- buttons: [
- .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
- return ActionSheet(
- title: Text("Connect with \(contact.chatViewName)"),
- buttons: [
- .default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
- if let incognito = incognito {
- return ActionSheet(
- title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
- buttons: [
- .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
- .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
+private func showAskCurrentOrIncognitoProfileSheet(
+ title: String,
+ actionStyle: UIAlertAction.Style = .default,
+ connectionLink: CreatedConnLink,
+ connectionPlan: ConnectionPlan?,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ title,
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: actionStyle,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: actionStyle,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
)
- } else {
- return ActionSheet(
- title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
- buttons: [
- .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
- .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
+ ]}
+ )
+}
+
+private func showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
+ contact: Contact,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Connect with %@", comment: "new chat action"),
+ contact.chatViewName
+ ),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
)
+ ]}
+ )
+}
+
+private func showOwnGroupLinkConfirmConnectSheet(
+ groupInfo: GroupInfo,
+ connectionLink: CreatedConnLink,
+ connectionPlan: ConnectionPlan?,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Open group", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: .destructive,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: .destructive,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
+ )
+ ]}
+ )
+}
+
+private func showPrepareContactAlert(
+ connectionLink: CreatedConnLink,
+ contactShortLinkData: ContactShortLinkData,
+ theme: AppTheme,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showOpenChatAlert(
+ profileName: contactShortLinkData.profile.displayName,
+ profileFullName: contactShortLinkData.profile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: contactShortLinkData.profile.image,
+ iconName: contactShortLinkData.business
+ ? "briefcase.circle.fill"
+ : contactShortLinkData.profile.peerType == .bot
+ ? "cube.fill"
+ : "person.crop.circle.fill",
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
+ onCancel: { cleanup?() },
+ onConfirm: {
+ Task {
+ do {
+ let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData)
+ await MainActor.run {
+ ChatModel.shared.addChat(Chat(chat))
+ openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
+ }
+ } catch let error {
+ logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)")
+ showAlert(NSLocalizedString("Error opening chat", comment: ""), message: responseError(error))
+ await MainActor.run {
+ cleanup?()
+ }
+ }
+ }
}
- }
+ )
+}
+
+private func showPrepareGroupAlert(
+ connectionLink: CreatedConnLink,
+ groupShortLinkData: GroupShortLinkData,
+ theme: AppTheme,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showOpenChatAlert(
+ profileName: groupShortLinkData.groupProfile.displayName,
+ profileFullName: groupShortLinkData.groupProfile.fullName,
+ profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"),
+ onCancel: { cleanup?() },
+ onConfirm: {
+ Task {
+ do {
+ let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData)
+ await MainActor.run {
+ ChatModel.shared.addChat(Chat(chat))
+ openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
+ }
+ } catch let error {
+ logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)")
+ showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error))
+ await MainActor.run {
+ cleanup?()
+ }
+ }
+ }
+ }
+ )
+}
+
+private func showOpenKnownContactAlert(
+ _ contact: Contact,
+ theme: AppTheme,
+ dismiss: Bool
+) {
+ showOpenChatAlert(
+ profileName: contact.profile.displayName,
+ profileFullName: contact.profile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: contact.profile.image,
+ iconName: contact.chatIconName,
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle:
+ contact.nextConnectPrepared
+ ? NSLocalizedString("Open new chat", comment: "new chat action")
+ : NSLocalizedString("Open chat", comment: "new chat action"),
+ onConfirm: {
+ openKnownContact(contact, dismiss: dismiss, cleanup: nil)
+ }
+ )
+}
+
+private func showOpenKnownGroupAlert(
+ _ groupInfo: GroupInfo,
+ theme: AppTheme,
+ dismiss: Bool
+) {
+ showOpenChatAlert(
+ profileName: groupInfo.groupProfile.displayName,
+ profileFullName: groupInfo.groupProfile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: groupInfo.groupProfile.image,
+ iconName: groupInfo.chatIconName,
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle:
+ groupInfo.businessChat == nil
+ ? ( groupInfo.nextConnectPrepared
+ ? NSLocalizedString("Open new group", comment: "new chat action")
+ : NSLocalizedString("Open group", comment: "new chat action")
+ )
+ : ( groupInfo.nextConnectPrepared
+ ? NSLocalizedString("Open new chat", comment: "new chat action")
+ : NSLocalizedString("Open chat", comment: "new chat action")
+ ),
+ onConfirm: {
+ openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
+ }
+ )
}
func planAndConnect(
- _ connectionLink: String,
- showAlert: @escaping (PlanAndConnectAlert) -> Void,
- showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
+ _ shortOrFullLink: String,
+ theme: AppTheme,
dismiss: Bool,
- incognito: Bool?,
cleanup: (() -> Void)? = nil,
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
- Task {
- do {
- let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
- switch connectionPlan {
- case let .invitationLink(ilp):
- switch ilp {
- case .ok:
- logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
- showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
- }
- case .ownLink:
- logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
- } else {
- showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
- }
- case let .connecting(contact_):
- logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
- if let contact = contact_ {
- if let f = filterKnownContact {
- f(contact)
+ ConnectProgressManager.shared.cancelConnectProgress()
+ let inProgress = BoxedValue(true)
+ connectTask(inProgress)
+ ConnectProgressManager.shared.startConnectProgress(NSLocalizedString("Loading profile…", comment: "in progress text")) {
+ inProgress.boxedValue = false
+ cleanup?()
+ }
+
+ func connectTask(_ inProgress: BoxedValue) {
+ Task {
+ let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress)
+ await MainActor.run {
+ ConnectProgressManager.shared.stopConnectProgress()
+ }
+ if !inProgress.boxedValue { return }
+ if let (connectionLink, connectionPlan) = result {
+ switch connectionPlan {
+ case let .invitationLink(ilp):
+ switch ilp {
+ case let .ok(contactSLinkData_):
+ if let contactSLinkData = contactSLinkData_ {
+ logger.debug("planAndConnect, .invitationLink, .ok, short link data present")
+ await MainActor.run {
+ showPrepareContactAlert(
+ connectionLink: connectionLink,
+ contactShortLinkData: contactSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
} else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
+ logger.debug("planAndConnect, .invitationLink, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case .ownLink:
+ logger.debug("planAndConnect, .invitationLink, .ownLink")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect to yourself?\nThis is your own one-time link!", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case let .connecting(contact_):
+ logger.debug("planAndConnect, .invitationLink, .connecting")
+ await MainActor.run {
+ if let contact = contact_ {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ } else {
+ showInvitationLinkConnectingAlert(cleanup: cleanup)
+ }
+ }
+ case let .known(contact):
+ logger.debug("planAndConnect, .invitationLink, .known")
+ await MainActor.run {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ }
+ case let .contactAddress(cap):
+ switch cap {
+ case let .ok(contactSLinkData_):
+ if let contactSLinkData = contactSLinkData_ {
+ logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
+ await MainActor.run {
+ showPrepareContactAlert(
+ connectionLink: connectionLink,
+ contactShortLinkData: contactSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ } else {
+ logger.debug("planAndConnect, .contactAddress, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case .ownLink:
+ logger.debug("planAndConnect, .contactAddress, .ownLink")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect to yourself?\nThis is your own SimpleX address!", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case .connectingConfirmReconnect:
+ logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("You have already requested connection!\nRepeat connection request?", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case let .connectingProhibit(contact):
+ logger.debug("planAndConnect, .contactAddress, .connectingProhibit")
+ await MainActor.run {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ case let .known(contact):
+ logger.debug("planAndConnect, .contactAddress, .known")
+ await MainActor.run {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ case let .contactViaAddress(contact):
+ logger.debug("planAndConnect, .contactAddress, .contactViaAddress")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
+ contact: contact,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case let .groupLink(glp):
+ switch glp {
+ case let .ok(groupSLinkData_):
+ if let groupSLinkData = groupSLinkData_ {
+ logger.debug("planAndConnect, .groupLink, .ok, short link data present")
+ await MainActor.run {
+ showPrepareGroupAlert(
+ connectionLink: connectionLink,
+ groupShortLinkData: groupSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ } else {
+ logger.debug("planAndConnect, .groupLink, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Join group", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case let .ownLink(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .ownLink")
+ await MainActor.run {
+ if let f = filterKnownGroup {
+ f(groupInfo)
+ }
+ showOwnGroupLinkConfirmConnectSheet(
+ groupInfo: groupInfo,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case .connectingConfirmReconnect:
+ logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("You are already joining the group!\nRepeat join request?", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case let .connectingProhibit(groupInfo_):
+ logger.debug("planAndConnect, .groupLink, .connectingProhibit")
+ await MainActor.run {
+ showGroupLinkConnectingAlert(groupInfo: groupInfo_, cleanup: cleanup)
+ }
+ case let .known(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .known")
+ await MainActor.run {
+ if let f = filterKnownGroup {
+ f(groupInfo)
+ } else {
+ showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss)
+ }
+ }
+ }
+ case let .error(chatError):
+ logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via link", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: nil,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ } else {
+ await MainActor.run {
+ if let alert {
+ dismissAllSheets(animated: true) {
+ AlertManager.shared.showAlert(alert)
+ cleanup?()
}
} else {
- showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
- }
- case let .known(contact):
- logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
- if let f = filterKnownContact {
- f(contact)
- } else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
+ cleanup?()
}
}
- case let .contactAddress(cap):
- switch cap {
- case .ok:
- logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
- showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
- }
- case .ownLink:
- logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
- } else {
- showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
- }
- case .connectingConfirmReconnect:
- logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
- } else {
- showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
- }
- case let .connectingProhibit(contact):
- logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
- if let f = filterKnownContact {
- f(contact)
- } else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
- }
- case let .known(contact):
- logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
- if let f = filterKnownContact {
- f(contact)
- } else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
- }
- case let .contactViaAddress(contact):
- logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
- showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
- }
- }
- case let .groupLink(glp):
- switch glp {
- case .ok:
- if let incognito = incognito {
- showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
- } else {
- showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
- }
- case let .ownLink(groupInfo):
- logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
- if let f = filterKnownGroup {
- f(groupInfo)
- }
- showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
- case .connectingConfirmReconnect:
- logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
- } else {
- showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
- }
- case let .connectingProhibit(groupInfo_):
- logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
- showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
- case let .known(groupInfo):
- logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
- if let f = filterKnownGroup {
- f(groupInfo)
- } else {
- openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
- }
- }
- }
- } catch {
- logger.debug("planAndConnect, plan error")
- if let incognito = incognito {
- connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
- showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
}
}
}
@@ -1161,22 +1436,22 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
}
private func connectViaLink(
- _ connectionLink: String,
+ _ connectionLink: CreatedConnLink,
connectionPlan: ConnectionPlan?,
dismiss: Bool,
incognito: Bool,
cleanup: (() -> Void)?
) {
Task {
- if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
+ if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
let crt: ConnReqType
- if let plan = connectionPlan {
- crt = planToConnReqType(plan)
+ crt = if let plan = connectionPlan {
+ planToConnReqType(plan) ?? connReqType
} else {
- crt = connReqType
+ connReqType
}
DispatchQueue.main.async {
if dismiss {
@@ -1198,41 +1473,29 @@ private func connectViaLink(
}
}
-func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
- Task {
- let m = ChatModel.shared
- if let c = m.getContactChat(contact.contactId) {
- DispatchQueue.main.async {
- if dismiss {
- dismissAllSheets(animated: true) {
- ItemsModel.shared.loadOpenChat(c.id)
- showAlreadyExistsAlert?()
- }
- } else {
- ItemsModel.shared.loadOpenChat(c.id)
- showAlreadyExistsAlert?()
- }
- }
- }
+func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) {
+ if let c = ChatModel.shared.getContactChat(contact.contactId) {
+ openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup)
}
}
-func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
- Task {
- let m = ChatModel.shared
- if let g = m.getGroupChat(groupInfo.groupId) {
- DispatchQueue.main.async {
- if dismiss {
- dismissAllSheets(animated: true) {
- ItemsModel.shared.loadOpenChat(g.id)
- showAlreadyExistsAlert?()
- }
- } else {
- ItemsModel.shared.loadOpenChat(g.id)
- showAlreadyExistsAlert?()
- }
+func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) {
+ if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) {
+ openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup)
+ }
+}
+
+func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) {
+ if dismiss {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(chatId) {
+ cleanup?()
}
}
+ } else {
+ ItemsModel.shared.loadOpenChat(chatId) {
+ cleanup?()
+ }
}
}
@@ -1269,11 +1532,12 @@ enum ConnReqType: Equatable {
}
}
-private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
+private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? {
switch connectionPlan {
- case .invitationLink: return .invitation
- case .contactAddress: return .contact
- case .groupLink: return .groupLink
+ case .invitationLink: .invitation
+ case .contactAddress: .contact
+ case .groupLink: .groupLink
+ case .error: nil
}
}
diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift
index bc1dc4b5bc..c9054f30da 100644
--- a/apps/ios/Shared/Views/NewChat/QRCode.swift
+++ b/apps/ios/Shared/Views/NewChat/QRCode.swift
@@ -8,18 +8,30 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
+import SimpleXChat
struct MutableQRCode: View {
@Binding var uri: String
+ var small: Bool = false
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var body: some View {
- QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
+ QRCode(uri: uri, small: small, withLogo: withLogo, tintColor: tintColor)
.id("simplex-qrcode-view-for-\(uri)")
}
}
+struct SimpleXCreatedLinkQRCode: View {
+ let link: CreatedConnLink
+ @Binding var short: Bool
+ var onShare: (() -> Void)? = nil
+
+ var body: some View {
+ QRCode(uri: link.simplexChatUri(short: short), small: short && link.connShortLink != nil, onShare: onShare)
+ }
+}
+
struct SimpleXLinkQRCode: View {
let uri: String
var withLogo: Bool = true
@@ -27,56 +39,57 @@ struct SimpleXLinkQRCode: View {
var onShare: (() -> Void)? = nil
var body: some View {
- QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare)
+ QRCode(uri: simplexChatLink(uri), small: uri.count < 200, withLogo: withLogo, tintColor: tintColor, onShare: onShare)
}
}
-func simplexChatLink(_ uri: String) -> String {
- uri.starts(with: "simplex:/")
- ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
- : uri
-}
+private let smallQRRatio: CGFloat = 0.63
struct QRCode: View {
let uri: String
+ var small: Bool = false
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var onShare: (() -> Void)? = nil
@State private var image: UIImage? = nil
@State private var makeScreenshotFunc: () -> Void = {}
+ @State private var width: CGFloat = .infinity
var body: some View {
ZStack {
if let image = image {
- qrCodeImage(image)
- GeometryReader { geo in
+ qrCodeImage(image).frame(width: width, height: width)
+ GeometryReader { g in
+ let w = g.size.width * (small ? smallQRRatio : 1)
+ let l = w * (small ? 0.195 : 0.16)
+ let m = w * 0.005
ZStack {
if withLogo {
- let w = geo.size.width
Image("icon-light")
.resizable()
.scaledToFit()
- .frame(width: w * 0.16, height: w * 0.16)
- .frame(width: w * 0.165, height: w * 0.165)
+ .frame(width: l, height: l)
+ .frame(width: l + m, height: l + m)
.background(.white)
.clipShape(Circle())
}
}
.onAppear {
+ width = w
makeScreenshotFunc = {
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
- showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
+ showShareSheet(items: [makeScreenshot(g.frame(in: .local).origin, size)])
onShare?()
}
}
- .frame(width: geo.size.width, height: geo.size.height)
+ .frame(width: g.size.width, height: g.size.height)
}
} else {
- Color.clear.aspectRatio(1, contentMode: .fit)
+ Color.clear.aspectRatio(small ? 1 / smallQRRatio : 1, contentMode: .fit)
}
}
.onTapGesture(perform: makeScreenshotFunc)
- .task { image = await generateImage(uri, tintColor: tintColor) }
+ .task { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -89,10 +102,11 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled)
}
-private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? {
+private func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
+ filter.correctionLevel = errorLevel
if let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
index 1a0a736acd..33ffa04a50 100644
--- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
+++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
@@ -43,110 +43,69 @@ struct OnboardingButtonStyle: ButtonStyle {
}
}
-private enum ChooseServerOperatorsSheet: Identifiable {
- case showInfo
+private enum OnboardingConditionsViewSheet: Identifiable {
case showConditions
+ case configureOperators
var id: String {
switch self {
- case .showInfo: return "showInfo"
case .showConditions: return "showConditions"
+ case .configureOperators: return "configureOperators"
}
}
}
-struct ChooseServerOperators: View {
- @Environment(\.dismiss) var dismiss: DismissAction
- @Environment(\.colorScheme) var colorScheme: ColorScheme
+struct OnboardingConditionsView: View {
@EnvironmentObject var theme: AppTheme
- var onboarding: Bool
@State private var serverOperators: [ServerOperator] = []
@State private var selectedOperatorIds = Set()
- @State private var sheetItem: ChooseServerOperatorsSheet? = nil
+ @State private var sheetItem: OnboardingConditionsViewSheet? = nil
@State private var notificationsModeNavLinkActive = false
@State private var justOpened = true
- var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } }
-
var body: some View {
GeometryReader { g in
- ScrollView {
+ let v = ScrollView {
VStack(alignment: .leading, spacing: 20) {
- let title = Text("Server operators")
+ Text("Conditions of use")
.font(.largeTitle)
.bold()
.frame(maxWidth: .infinity, alignment: .center)
-
- if onboarding {
- title.padding(.top, 25)
- } else {
- title
- }
-
- infoText()
- .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.top, 25)
Spacer()
-
- ForEach(serverOperators) { srvOperator in
- operatorCheckView(srvOperator)
+
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Private chats, groups and your contacts are not accessible to server operators.")
+ .lineSpacing(2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("""
+ By using SimpleX Chat you agree to:
+ - send only legal content in public groups.
+ - respect other users – no spam.
+ """)
+ .lineSpacing(2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Button("Privacy policy and conditions of use.") {
+ sheetItem = .showConditions
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- VStack {
- Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
- Text("You can configure servers via settings.")
- }
- .font(.footnote)
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.horizontal, 16)
-
+ .padding(.horizontal, 4)
+
Spacer()
-
- let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
- let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
- let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
-
- VStack(spacing: 8) {
- if !reviewForOperators.isEmpty {
- reviewConditionsButton()
- } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
- setOperatorsButton()
- } else {
- continueButton()
+
+ VStack(spacing: 12) {
+ acceptConditionsButton()
+
+ Button("Configure server operators") {
+ sheetItem = .configureOperators
}
- if onboarding {
- Group {
- if reviewForOperators.isEmpty {
- Button("Conditions of use") {
- sheetItem = .showConditions
- }
- } else {
- Text("Conditions of use")
- .foregroundColor(.clear)
- }
- }
- .font(.system(size: 17, weight: .semibold))
- .frame(minHeight: 40)
- }
- }
-
- if !onboarding && !reviewForOperators.isEmpty {
- VStack(spacing: 8) {
- reviewLaterButton()
- (
- Text("Conditions will be accepted for enabled operators after 30 days.")
- + textSpace
- + Text("You can configure operators in Network & servers settings.")
- )
- .multilineTextAlignment(.center)
- .font(.footnote)
- .padding(.horizontal, 32)
- }
- .frame(maxWidth: .infinity)
- .disabled(!canReviewLater)
- .padding(.bottom)
+ .frame(minHeight: 40)
}
}
+ .padding(25)
.frame(minHeight: g.size.height)
}
.onAppear {
@@ -158,130 +117,28 @@ struct ChooseServerOperators: View {
}
.sheet(item: $sheetItem) { item in
switch item {
- case .showInfo:
- ChooseServerOperatorsInfoView()
case .showConditions:
- UsageConditionsView(
- currUserServers: Binding.constant([]),
- userServers: Binding.constant([])
- )
- .modifier(ThemedBackground(grouped: true))
+ SimpleConditionsView()
+ .modifier(ThemedBackground(grouped: true))
+ case .configureOperators:
+ ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds)
+ .modifier(ThemedBackground())
}
}
.frame(maxHeight: .infinity, alignment: .top)
+ if #available(iOS 16.4, *) {
+ v.scrollBounceBehavior(.basedOnSize)
+ } else {
+ v
+ }
}
.frame(maxHeight: .infinity, alignment: .top)
- .padding(onboarding ? 25 : 16)
- }
-
- private func infoText() -> some View {
- Button {
- sheetItem = .showInfo
- } label: {
- Label("How it helps privacy", systemImage: "info.circle")
- .font(.headline)
- }
- }
-
- @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
- let checked = selectedOperatorIds.contains(serverOperator.operatorId)
- let icon = checked ? "checkmark.circle.fill" : "circle"
- let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
- HStack(spacing: 10) {
- Image(serverOperator.largeLogo(colorScheme))
- .resizable()
- .scaledToFit()
- .frame(height: 48)
- Spacer()
- Image(systemName: icon)
- .resizable()
- .scaledToFit()
- .frame(width: 26, height: 26)
- .foregroundColor(iconColor)
- }
- .background(theme.colors.background)
- .padding()
- .clipShape(RoundedRectangle(cornerRadius: 18))
- .overlay(
- RoundedRectangle(cornerRadius: 18)
- .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
- )
- .padding(.horizontal, 2)
- .onTapGesture {
- if checked {
- selectedOperatorIds.remove(serverOperator.operatorId)
- } else {
- selectedOperatorIds.insert(serverOperator.operatorId)
- }
- }
- }
-
- private func reviewConditionsButton() -> some View {
- NavigationLink("Review conditions") {
- reviewConditionsView()
- .navigationTitle("Conditions of use")
- .navigationBarTitleDisplayMode(.large)
- .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
- .modifier(ThemedBackground(grouped: true))
- }
- .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
- .disabled(selectedOperatorIds.isEmpty)
- }
-
- private func setOperatorsButton() -> some View {
- notificationsModeNavLinkButton {
- Button {
- Task {
- if let enabledOperators = enabledOperators(serverOperators) {
- let r = try await setServerOperators(operators: enabledOperators)
- await MainActor.run {
- ChatModel.shared.conditions = r
- continueToNextStep()
- }
- } else {
- await MainActor.run {
- continueToNextStep()
- }
- }
- }
- } label: {
- Text("Update")
- }
- .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
- .disabled(selectedOperatorIds.isEmpty)
- }
- }
-
- private func continueButton() -> some View {
- notificationsModeNavLinkButton {
- Button {
- continueToNextStep()
- } label: {
- Text("Continue")
- }
- .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
- .disabled(selectedOperatorIds.isEmpty)
- }
- }
-
- private func reviewLaterButton() -> some View {
- notificationsModeNavLinkButton {
- Button {
- continueToNextStep()
- } label: {
- Text("Review later")
- }
- .buttonStyle(.borderless)
- }
+ .navigationBarHidden(true) // necessary on iOS 15
}
private func continueToNextStep() {
- if onboarding {
- onboardingStageDefault.set(.step4_SetNotificationsMode)
- notificationsModeNavLinkActive = true
- } else {
- dismiss()
- }
+ onboardingStageDefault.set(.step4_SetNotificationsMode)
+ notificationsModeNavLinkActive = true
}
func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
@@ -304,34 +161,13 @@ struct ChooseServerOperators: View {
.modifier(ThemedBackground())
}
- @ViewBuilder private func reviewConditionsView() -> some View {
- let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
- let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
- VStack(alignment: .leading, spacing: 20) {
- if !operatorsWithConditionsAccepted.isEmpty {
- Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
- Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
- } else {
- Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
- }
- ConditionsTextView()
- .frame(maxHeight: .infinity)
- acceptConditionsButton()
- .padding(.bottom)
- .padding(.bottom)
- }
- .padding(.horizontal, 25)
- }
-
private func acceptConditionsButton() -> some View {
notificationsModeNavLinkButton {
Button {
Task {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
- let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
- let operatorIds = acceptForOperators.map { $0.operatorId }
- let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
+ let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds))
await MainActor.run {
ChatModel.shared.conditions = r
}
@@ -356,9 +192,10 @@ struct ChooseServerOperators: View {
}
}
} label: {
- Text("Accept conditions")
+ Text("Accept")
}
- .buttonStyle(OnboardingButtonStyle())
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
}
}
@@ -393,6 +230,126 @@ struct ChooseServerOperators: View {
}
}
+private enum ChooseServerOperatorsSheet: Identifiable {
+ case showInfo
+
+ var id: String {
+ switch self {
+ case .showInfo: return "showInfo"
+ }
+ }
+}
+
+struct ChooseServerOperators: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+ var serverOperators: [ServerOperator]
+ @Binding var selectedOperatorIds: Set
+ @State private var sheetItem: ChooseServerOperatorsSheet? = nil
+
+ var body: some View {
+ GeometryReader { g in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Server operators")
+ .font(.largeTitle)
+ .bold()
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.top, 25)
+
+ infoText()
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Spacer()
+
+ ForEach(serverOperators) { srvOperator in
+ operatorCheckView(srvOperator)
+ }
+ VStack {
+ Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
+ Text("You can configure servers via settings.")
+ }
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 16)
+
+ Spacer()
+
+ VStack(spacing: 8) {
+ setOperatorsButton()
+ onboardingButtonPlaceholder()
+ }
+ }
+ .frame(minHeight: g.size.height)
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showInfo:
+ ChooseServerOperatorsInfoView()
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ .padding(25)
+ .interactiveDismissDisabled(selectedOperatorIds.isEmpty)
+ }
+
+ private func infoText() -> some View {
+ Button {
+ sheetItem = .showInfo
+ } label: {
+ Label("How it helps privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
+
+ private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
+ let checked = selectedOperatorIds.contains(serverOperator.operatorId)
+ let icon = checked ? "checkmark.circle.fill" : "circle"
+ let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
+ return HStack(spacing: 10) {
+ Image(serverOperator.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ Spacer()
+ Image(systemName: icon)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 26, height: 26)
+ .foregroundColor(iconColor)
+ }
+ .background(theme.colors.background)
+ .padding()
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18)
+ .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
+ )
+ .padding(.horizontal, 2)
+ .onTapGesture {
+ if checked {
+ selectedOperatorIds.remove(serverOperator.operatorId)
+ } else {
+ selectedOperatorIds.insert(serverOperator.operatorId)
+ }
+ }
+ }
+
+ private func setOperatorsButton() -> some View {
+ Button {
+ dismiss()
+ } label: {
+ Text("OK")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+}
+
let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")!
struct ChooseServerOperatorsInfoView: View {
@@ -447,5 +404,5 @@ struct ChooseServerOperatorsInfoView: View {
}
#Preview {
- ChooseServerOperators(onboarding: true)
+ OnboardingConditionsView()
}
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
index 409cb859ea..f119beec50 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -25,10 +25,13 @@ enum UserProfileAlert: Identifiable {
}
}
+let MAX_BIO_LENGTH_BYTES = 160
+
struct CreateProfile: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var displayName: String = ""
+ @State private var profileBio: String = ""
@FocusState private var focusDisplayName
@State private var alert: UserProfileAlert?
@@ -37,12 +40,13 @@ struct CreateProfile: View {
Section {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
+ TextField("Bio", text: $profileBio)
Button {
createProfile()
} label: {
Label("Create profile", systemImage: "checkmark")
}
- .disabled(!canCreateProfile(displayName))
+ .disabled(!canCreateProfile(displayName) || !bioFitsLimit())
} header: {
HStack {
Text("Your profile")
@@ -52,18 +56,20 @@ struct CreateProfile: View {
let validName = mkValidName(name)
if name != validName {
Spacer()
- Image(systemName: "exclamationmark.circle")
- .foregroundColor(.red)
- .onTapGesture {
- alert = .invalidNameError(validName: validName)
- }
+ validationErrorIndicator {
+ alert = .invalidNameError(validName: validName)
+ }
+ } else if !bioFitsLimit() {
+ Spacer()
+ validationErrorIndicator {
+ showAlert(NSLocalizedString("Bio too large", comment: "alert title"))
+ }
}
}
.frame(height: 20)
} footer: {
VStack(alignment: .leading, spacing: 8) {
- Text("Your profile, contacts and delivered messages are stored on your device.")
- Text("The profile is only shared with your contacts.")
+ Text("Your profile is stored on your device and only shared with your contacts.")
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -79,11 +85,25 @@ struct CreateProfile: View {
}
}
+ private func validationErrorIndicator(_ onTap: @escaping () -> Void) -> some View {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .onTapGesture {
+ onTap()
+ }
+ }
+
+ private func bioFitsLimit() -> Bool {
+ chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES
+ }
+
private func createProfile() {
hideKeyboard()
+ let shortDescr: String? = if profileBio.isEmpty { nil } else { profileBio }
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
- fullName: ""
+ fullName: "",
+ shortDescr: shortDescr
)
let m = ChatModel.shared
do {
@@ -118,25 +138,22 @@ struct CreateFirstProfile: View {
@State private var nextStepNavLinkActive = false
var body: some View {
- VStack(alignment: .leading, spacing: 20) {
- VStack(alignment: .center, spacing: 20) {
- Text("Create your profile")
+ let v = VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .center, spacing: 16) {
+ Text("Create profile")
.font(.largeTitle)
.bold()
.multilineTextAlignment(.center)
-
- Text("Your profile, contacts and delivered messages are stored on your device.")
- .font(.callout)
- .foregroundColor(theme.colors.secondary)
- .multilineTextAlignment(.center)
-
- Text("The profile is only shared with your contacts.")
+
+ Text("Your profile is stored on your device and only shared with your contacts.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
}
+ .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity) // Ensures it takes up the full width
.padding(.horizontal, 10)
+ .onTapGesture { focusDisplayName = false }
HStack {
let name = displayName.trimmingCharacters(in: .whitespaces)
@@ -145,6 +162,7 @@ struct CreateFirstProfile: View {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
.padding(.horizontal)
+ .padding(.trailing, 20)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
@@ -173,12 +191,23 @@ struct CreateFirstProfile: View {
}
}
.onAppear() {
- focusDisplayName = true
+ if #available(iOS 16, *) {
+ focusDisplayName = true
+ } else {
+ // it does not work before animation completes on iOS 15
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ focusDisplayName = true
+ }
+ }
}
.padding(.horizontal, 25)
- .padding(.top, 10)
.padding(.bottom, 25)
.frame(maxWidth: .infinity, alignment: .leading)
+ if #available(iOS 16, *) {
+ return v.padding(.top, 10)
+ } else {
+ return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top)
+ }
}
func createProfileButton() -> some View {
@@ -206,7 +235,7 @@ struct CreateFirstProfile: View {
}
private func nextStepDestinationView() -> some View {
- ChooseServerOperators(onboarding: true)
+ OnboardingConditionsView()
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
}
@@ -235,15 +264,15 @@ private func showCreateProfileAlert(
_ error: Error
) {
let m = ChatModel.shared
- switch error as? ChatResponse {
- case .chatCmdError(_, .errorStore(.duplicateName)),
- .chatCmdError(_, .error(.userExists)):
+ switch error as? ChatError {
+ case .errorStore(.duplicateName),
+ .error(.userExists):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
showAlert(.duplicateUserError)
}
- case .chatCmdError(_, .error(.invalidDisplayName)):
+ case .error(.invalidDisplayName):
if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else {
diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
index befb34b318..03b0fcba1a 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
Spacer()
if let userAddress = m.userAddress {
- SimpleXLinkQRCode(uri: userAddress.connReqContact)
+ SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false))
.frame(maxHeight: g.size.width)
shareQRCodeButton(userAddress)
.frame(maxWidth: .infinity)
@@ -77,9 +77,10 @@ struct CreateSimpleXAddress: View {
progressIndicator = true
Task {
do {
- let connReqContact = try await apiCreateUserAddress()
- DispatchQueue.main.async {
- m.userAddress = UserContactLink(connReqContact: connReqContact)
+ if let connLinkContact = try await apiCreateUserAddress() {
+ DispatchQueue.main.async {
+ m.userAddress = UserContactLink(connLinkContact)
+ }
}
await MainActor.run { progressIndicator = false }
} catch let error {
@@ -121,7 +122,7 @@ struct CreateSimpleXAddress: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button {
- showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
+ showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))])
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
@@ -189,7 +190,7 @@ struct SendAddressMailView: View {
let messageBody = String(format: NSLocalizedString("""