Compare commits

..

No commits in common. "stable" and "v6.0.0-beta.1" have entirely different histories.

1447 changed files with 62835 additions and 185298 deletions

View file

@ -1,47 +0,0 @@
name: "Prebuilt steps for build"
description: "Reusable steps for multiple jobs"
inputs:
java_ver:
required: true
description: "Java version to install"
ghc_ver:
required: true
description: "GHC version to install"
github_ref:
required: true
description: "Git reference"
os:
required: true
description: "Target OS"
cache_path:
required: false
default: "~/.cabal/store"
description: "Cache path"
cabal_ver:
required: false
default: 3.10.1.0
description: "GHC version to install"
runs:
using: "composite"
steps:
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ inputs.ghc_ver }}
cabal-version: ${{ inputs.cabal_ver }}
- name: Setup Java
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: ${{ inputs.java_ver }}
cache: 'gradle'
- name: Restore cached build
uses: actions/cache@v4
with:
path: |
${{ inputs.cache_path }}
dist-newstyle
key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}

View file

@ -1,39 +0,0 @@
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 }}

View file

@ -1,44 +0,0 @@
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

View file

@ -5,75 +5,24 @@ on:
branches: branches:
- master - master
- stable - stable
- users
tags: tags:
- "v*" - "v*"
- "!*-fdroid" - "!*-fdroid"
- "!*-armv7a" - "!*-armv7a"
pull_request: pull_request:
paths-ignore:
- "apps/ios"
- "apps/multiplatform"
- "blog"
- "docs"
- "fastlane"
- "images"
- "packages"
- "website"
- "README.md"
- "PRIVACY.md"
# This workflow uses custom actions (prepare-build and prepare-release) defined in:
#
# .github/actions/
# ├── prepare-build
# │ └── action.yml
# └── prepare-release
# └── action.yml
# Important!
# Do not use always(), it makes build unskippable.
# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs: 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 runs-on: ubuntu-latest
steps: steps:
- name: Clone project - name: Clone project
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build changelog - name: Build changelog
id: build_changelog id: build_changelog
if: startsWith(github.ref, 'refs/tags/v') uses: mikepenz/release-changelog-builder-action@v4
uses: simplex-chat/release-changelog-builder-action@v5
with: with:
configuration: .github/changelog_conf.json configuration: .github/changelog_conf.json
failOnError: true failOnError: true
@ -83,8 +32,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release - name: Create release
if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v1
uses: simplex-chat/action-gh-release@v2
with: with:
body: ${{ steps.build_changelog.outputs.changelog }} body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true prerelease: true
@ -94,295 +42,178 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ========================= build:
# Linux Build name: build-${{ matrix.os }}-${{ matrix.ghc }}
# ========================= if: always()
needs: prepare-release
build-linux: runs-on: ${{ matrix.os }}
name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ubuntu-${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: 22.04 - os: ubuntu-20.04
ghc: "8.10.7" ghc: "8.10.7"
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} cache_path: ~/.cabal/store
- os: 22.04 - os: ubuntu-20.04
ghc: ${{ needs.variables.outputs.GHC_VER }} ghc: "9.6.3"
cli_asset_name: simplex-chat-ubuntu-22_04-x86-64 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 desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
should_run: true - os: macos-latest
- os: 24.04 ghc: "9.6.3"
ghc: ${{ needs.variables.outputs.GHC_VER }} cache_path: ~/.cabal/store
cli_asset_name: simplex-chat-ubuntu-24_04-x86-64 asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb desktop_asset_name: simplex-desktop-macos-aarch64.dmg
should_run: true - 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
steps: steps:
- name: Checkout Code - name: Configure pagefile (Windows)
if: matrix.should_run == true 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
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup swap - name: Setup Haskell
if: matrix.ghc == '8.10.7' && matrix.should_run == true uses: haskell-actions/setup@v2
uses: ./.github/actions/swap
with: with:
swap-size-gb: 30 ghc-version: ${{ matrix.ghc }}
cabal-version: "3.10.1.0"
# 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 - name: Restore cached build
if: matrix.should_run == true id: restore_cache
uses: actions/cache@v4 uses: actions/cache/restore@v3
with: with:
path: | path: |
~/.cabal/store ${{ matrix.cache_path }}
dist-newstyle dist-newstyle
key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- name: Set up Docker Buildx # / Unix
if: matrix.should_run == true
uses: simplex-chat/docker-setup-buildx-action@v3
- name: Build and cache Docker image - name: Unix prepare cabal.project.local for Mac
if: matrix.should_run == true if: matrix.os == 'macos-latest'
uses: simplex-chat/docker-build-push-action@v6
with:
context: .
load: true
file: Dockerfile.build
tags: build/${{ matrix.os }}:latest
build-args: |
TAG=${{ matrix.os }}
GHC=${{ matrix.ghc }}
# Docker needs these flags for AppImage build:
# --device /dev/fuse
# --cap-add SYS_ADMIN
# --security-opt apparmor:unconfined
- name: Start container
if: matrix.should_run == true
shell: bash shell: bash
run: | run: |
docker run -t -d \ echo "ignore-project: False" >> cabal.project.local
--device /dev/fuse \ echo "package simplexmq" >> cabal.project.local
--cap-add SYS_ADMIN \ echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
--security-opt apparmor:unconfined \ echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
--name builder \ echo "" >> cabal.project.local
-v ~/.cabal:/root/.cabal \ echo "package direct-sqlcipher" >> cabal.project.local
-v /home/runner/work/_temp:/home/runner/work/_temp \ echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
-v ${{ github.workspace }}:/project \ echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
build/${{ matrix.os }}:latest echo " flags: +openssl" >> cabal.project.local
- name: Prepare cabal.project.local - name: Unix prepare cabal.project.local for Mac
if: matrix.should_run == true if: matrix.os == 'macos-13'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
run: brew install pkg-config
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
# chmod/git commands are used to workaround permission issues when cache is restored - name: Unix build CLI
- name: Build CLI id: unix_cli_build
if: matrix.should_run == true if: matrix.os != 'windows-latest'
shell: docker exec -t builder sh -eu {0}
run: |
chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
cabal clean
cabal update
cabal build -j --enable-tests
mkdir -p /out
for i in simplex-chat simplex-chat-test; do
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
chmod +x "$bin"
mv "$bin" /out/
done
strip /out/simplex-chat
- name: Copy tests from container
if: matrix.should_run == true
shell: bash shell: bash
run: | run: |
docker cp builder:/out/simplex-chat-test . cabal build --enable-tests
path=$(cabal list-bin simplex-chat)
- name: Copy CLI from container and prepare it
id: linux_cli_prepare
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI - name: Unix upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
uses: ./.github/actions/prepare-release uses: svenstaro/upload-release-action@v2
with: with:
bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} repo_token: ${{ secrets.GITHUB_TOKEN }}
bin_name: ${{ matrix.cli_asset_name }} file: ${{ steps.unix_cli_build.outputs.bin_path }}
bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }} asset_name: ${{ matrix.asset_name }}
github_ref: ${{ github.ref }} tag: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Desktop - name: Unix update CLI binary hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
shell: docker exec -t builder sh -eu {0} uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.unix_cli_build.outputs.bin_hash }}
- name: Setup Java
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
cache: 'gradle'
- name: Linux build desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
shell: bash
run: | run: |
scripts/desktop/build-lib-linux.sh scripts/desktop/build-lib-linux.sh
cd apps/multiplatform cd apps/multiplatform
./gradlew packageDeb ./gradlew packageDeb
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
- name: Prepare Desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop - name: Linux make AppImage
uses: ./.github/actions/prepare-release id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
with: shell: bash
bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
bin_name: ${{ matrix.desktop_asset_name }}
bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build AppImage
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: | run: |
scripts/desktop/make-appimage-linux.sh scripts/desktop/make-appimage-linux.sh
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
- 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_path=$path" >> $GITHUB_OUTPUT
echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload AppImage - name: Mac build desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
bin_name: "simplex-desktop-x86_64.AppImage"
bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Fix permissions for cache
if: matrix.should_run == true
shell: bash
run: |
sudo chmod -R 777 dist-newstyle ~/.cabal
sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
- name: Run tests
if: matrix.should_run == true
timeout-minutes: 120
shell: bash
run: |
i=1
attempts=1
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
while [ "$i" -le "$attempts" ]; do
if ./simplex-chat-test; then
break
else
echo "Attempt $i failed, retrying..."
i=$((i + 1))
sleep 1
fi
done
if [ "$i" -gt "$attempts" ]; then
echo "All "$attempts" attempts failed."
exit 1
fi
# =========================
# MacOS Build
# =========================
build-macos:
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
openssl_dir: "/opt/homebrew/opt"
- os: macos-13
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
openssl_dir: "/usr/local/opt"
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Prepare build
uses: ./.github/actions/prepare-build
with:
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
ghc_ver: ${{ matrix.ghc }}
os: ${{ matrix.os }}
github_ref: ${{ github.ref }}
- name: Install OpenSSL
run: brew install openssl@3.0
- name: Prepare cabal.project.local
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Build CLI
id: mac_cli_build
shell: bash
run: |
cabal build -j --enable-tests
path=$(cabal list-bin simplex-chat)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
bin_name: ${{ matrix.cli_asset_name }}
bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Desktop
id: mac_desktop_build id: mac_desktop_build
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
shell: bash shell: bash
env: env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@ -392,77 +223,88 @@ jobs:
scripts/ci/build-desktop-mac.sh scripts/ci/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop - name: Linux upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: ./.github/actions/prepare-release uses: svenstaro/upload-release-action@v2
with: with:
bin_path: ${{ steps.mac_desktop_build.outputs.package_path }} repo_token: ${{ secrets.GITHUB_TOKEN }}
bin_name: ${{ matrix.desktop_asset_name }} file: ${{ steps.linux_desktop_build.outputs.package_path }}
bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }} asset_name: ${{ matrix.desktop_asset_name }}
github_ref: ${{ github.ref }} tag: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Run tests - name: Linux update desktop package hash
timeout-minutes: 120 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
shell: bash shell: bash
run: | run: cabal test --test-show-details=direct
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
# =========================
build-windows: # / Windows
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
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' - name: 'Setup MSYS2'
uses: simplex-chat/setup-msys2@v2 if: matrix.os == 'windows-latest'
uses: msys2/setup-msys2@v2
with: with:
msystem: ucrt64 msystem: ucrt64
update: true update: true
@ -474,14 +316,15 @@ jobs:
toolchain:p toolchain:p
cmake:p cmake:p
# 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 - name: Windows build
id: windows_cli_build id: windows_build
if: matrix.os == 'windows-latest'
shell: msys2 {0} shell: msys2 {0}
run: | run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/prepare-openssl-windows.sh scripts/desktop/prepare-openssl-windows.sh
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
rm cabal.project.local 2>/dev/null || true rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
@ -491,42 +334,70 @@ jobs:
rm -rf dist-newstyle/src/direct-sq* rm -rf dist-newstyle/src/direct-sq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal sed -i "s/, unix /--, unix /" simplex-chat.cabal
cabal build -j --enable-tests cabal build --enable-tests
rm -rf dist-newstyle/src/direct-sq* rm -rf dist-newstyle/src/direct-sq*
path=$(cabal list-bin simplex-chat | tail -n 1) path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT 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 echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI - name: Windows upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: ./.github/actions/prepare-release uses: svenstaro/upload-release-action@v2
with: with:
bin_path: ${{ steps.windows_cli_build.outputs.bin_path }} repo_token: ${{ secrets.GITHUB_TOKEN }}
bin_name: ${{ matrix.cli_asset_name }} file: ${{ steps.windows_build.outputs.bin_path }}
bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }} asset_name: ${{ matrix.asset_name }}
github_ref: ${{ github.ref }} tag: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Desktop - 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
id: windows_desktop_build id: windows_desktop_build
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
shell: msys2 {0} shell: msys2 {0}
run: | run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/build-lib-windows.sh scripts/desktop/build-lib-windows.sh
cd apps/multiplatform cd apps/multiplatform
./gradlew packageMsi ./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') path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop - name: Windows upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: ./.github/actions/prepare-release uses: svenstaro/upload-release-action@v2
with: with:
bin_path: ${{ steps.windows_desktop_build.outputs.package_path }} repo_token: ${{ secrets.GITHUB_TOKEN }}
bin_name: ${{ matrix.desktop_asset_name }} file: ${{ steps.windows_desktop_build.outputs.package_path }}
bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} asset_name: ${{ matrix.desktop_asset_name }}
github_ref: ${{ github.ref }} tag: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- 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 /

View file

@ -1,45 +0,0 @@
name: Reproduce latest release
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *' # every day at 02:00 night
jobs:
reproduce:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get latest release
shell: bash
run: |
curl --proto '=https' \
--tlsv1.2 \
-sSf -L \
'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
2>/dev/null | \
grep -i "tag_name" | \
awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
- name: Execute reproduce script
run: |
${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
- name: Check if build has been reproduced
env:
url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
run: |
if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
exit 0
else
curl --proto '=https' --tlsv1.2 -sSf \
-u "${user}:${pass}" \
-H 'Content-Type: application/json' \
-d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
"$url"
exit 1
fi

View file

@ -10,7 +10,6 @@ on:
- blog/** - blog/**
- docs/** - docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
- PRIVACY.md
jobs: jobs:
build: build:
@ -33,7 +32,7 @@ jobs:
./website/web.sh ./website/web.sh
- name: Deploy - name: Deploy
uses: simplex-chat/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v3
with: with:
publish_dir: ./website/_site publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -61,7 +61,6 @@ website/package/generated*
# Ignore build tool output, e.g. code coverage # Ignore build tool output, e.g. code coverage
website/.nyc_output/ website/.nyc_output/
website/coverage/ website/coverage/
result
# Ignore API documentation # Ignore API documentation
website/api-docs/ website/api-docs/

View file

@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat # Compile simplex-chat
RUN cabal update RUN cabal update
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' RUN cabal build exe:simplex-chat
# Strip the binary from debug symbols to reduce size # Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \

View file

@ -1,92 +0,0 @@
# syntax=docker/dockerfile:1.7.0-labs
ARG TAG=24.04
FROM ubuntu:${TAG} AS build
### Build stage
ARG GHC=9.6.3
ARG CABAL=3.10.1.0
ARG JAVA=17
ENV TZ=Etc/UTC \
DEBIAN_FRONTEND=noninteractive
# Install curl, git and and simplex-chat dependencies
RUN apt-get update && \
apt-get install -y curl \
libpq-dev \
git \
sqlite3 \
libsqlite3-dev \
build-essential \
libgmp3-dev \
zlib1g-dev \
llvm \
cmake \
llvm-dev \
libnuma-dev \
libssl-dev \
desktop-file-utils \
patchelf \
ca-certificates \
zip \
wget \
fuse3 \
file \
appstream \
gpg \
unzip &&\
ln -s /bin/fusermount /bin/fusermount3 || :
# Install Java Coretto
# Required, because official Java in Ubuntu
# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
# to fix this :(
RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
apt update &&\
apt install -y java-${JAVA}-amazon-corretto-jdk
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
# Do not install Stack
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
# Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Set both as default
RUN ghcup set ghc "${GHC}" && \
ghcup set cabal "${CABAL}"
#=====================
# Install Android SDK
#=====================
ARG SDK_VERSION=13114758
ENV SDK_VERSION=$SDK_VERSION \
ANDROID_HOME=/root
RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
unzip tools.zip && rm tools.zip && \
mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
RUN mkdir -p ~/.android ~/.gradle && \
touch ~/.android/repositories.cfg && \
echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
yes | sdkmanager --licenses >/dev/null
ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
WORKDIR /project

View file

@ -3,93 +3,63 @@ layout: layouts/privacy.html
permalink: /privacy/index.html permalink: /privacy/index.html
--- ---
# SimpleX Chat Operators Privacy Policy and Conditions of Use # SimpleX Chat Privacy Policy and Conditions of Use
## Summary SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network. SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
[Privacy policy](#privacy-policy) covers:
- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
- [how users connect](#connections-with-other-users) without any user profile identifiers.
- [iOS push notifications](#ios-push-notifications) privacy limitations.
- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
- [source code license](#source-code-license) and [updates to this document](#updates).
[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
## Introduction
SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)). Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy ## Privacy Policy
### General principles SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack). SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers. While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers. We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability. SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
### Your information ### Your information
#### User profiles #### User profiles
Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app. Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users. When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption. You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
#### Messages and Files #### Messages and Files
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band. SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too. Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
#### Private message delivery You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts. The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
#### Storage of messages and files on the servers
The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers). The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage. If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
#### Connections with other users #### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default. When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy #### Connection links privacy
@ -105,8 +75,6 @@ You can always safely replace the initial part of the link `https://simplex.chat
#### iOS Push Notifications #### iOS Push Notifications
This section applies only to the notification servers operated by SimpleX Chat Ltd.
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue. When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
@ -115,132 +83,93 @@ You can read more about the design of iOS push notifications [here](./blog/20220
#### Another information stored on the servers #### Another information stored on the servers
Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively. Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
#### SimpleX Directory #### SimpleX Directory
This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). [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 #### 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. If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
### Preset Server Operators ### Information we may share
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
Preset server operators must not provide general access to their servers or the data on their servers to each other. We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
Preset server operators 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. The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
### Information Preset Server Operators May Share
The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order. - To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations. - To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues. - 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. - To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
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. At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md). We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
### Source code license
As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates ### Updates
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app. We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy. Please also read our Conditions of Use of Software and Infrastructure below.
Please also read The Conditions of Use of Software and Infrastructure below. If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure ## Conditions of Use of Software and Infrastructure
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not. You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country. **Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated. **Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed. **Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks. **Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way. **Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages. **Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way. **Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes. **Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
**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. **Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
**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. **Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App: **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.
- is compatible with the protocol specifications not older than 1 year,
- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots),
- implements the same limits, rules and restrictions as Software,
- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure,
- displays the notice that it is the App for using SimpleX network,
- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license,
- does NOT use the branding of SimpleX Chat Ltd without the permission,
- does NOT pretend to be Software,
- complies with these Conditions of use.
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings. **Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted. **Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. **No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. **Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design. **Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE). **License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. **SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. **Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. **Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time. **Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions. **Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications. **Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat. **Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
**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. **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
Updated March 3, 2025 Updated April 24, 2024

109
README.md
View file

@ -10,7 +10,7 @@
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) [<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat! ## Welcome to SimpleX Chat!
@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates). 2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend. 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents). [Learn more about SimpleX Chat](#contents).
@ -72,7 +72,7 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. 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-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D) You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
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://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users: 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). [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
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 either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@ -110,15 +110,6 @@ 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). Read about the app features and settings in the new [User guide](./docs/guide/README.md).
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help translating SimpleX Chat ## 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. 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.
@ -150,7 +141,16 @@ 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! 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!
## Please support us with your donations ## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@ -163,15 +163,13 @@ Your donations help us raise more funds - any amount, even the price of the cup
It is possible to donate via: It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). - [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u - Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg - BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - USDT:
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg - Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq - Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins. - please ask if you want to donate any other coins.
Thank you, Thank you,
@ -235,28 +233,32 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md) [Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) [Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). [Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). [Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). [Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[All updates](./blog) [All updates](./blog)
## :zap: Quick installation of a terminal app ## :zap: Quick installation of a terminal app
@ -296,28 +298,25 @@ What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues. 1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation. 2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message. 3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation. 4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well). 5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks. 6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed. 7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key. 9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing). 10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers. 11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details. 12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings. 13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections. 14. Local files encryption.
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: 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). 1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 2. Post-quantum resistant key exchange in double ratchet protocol.
3. Reproducible clients builds this is a complex problem, but we are aiming to have it in 2025 at least partially. 3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
## For developers ## For developers
@ -385,11 +384,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos). - ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol. - ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). - ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Support multiple network operators in the app.
- 🏗 Large groups, communities and public channels.
- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage. - 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users. - 🏗 Improve experience for the new users.
- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once. - Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - Include optional message into connection request sent via contact address.
@ -408,9 +405,7 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0. [SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved. SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.

View file

@ -15,12 +15,35 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions") logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled() removePasscodesIfReinstalled()
prepareForLaunch() prepareForLaunch()
deleteOldChatArchive()
return true return true
} }
@available(iOS 17.0, *)
private func trackKeyboard() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@available(iOS 17.0, *)
@objc func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
}
}
@available(iOS 17.0, *)
@objc func keyboardWillHide(_ notification: Notification) {
ChatModel.shared.keyboardHeight = 0
}
@objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
@ -54,7 +77,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
try await apiVerifyToken(token: token, nonce: nonce, code: verification) try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active m.tokenStatus = .active
} catch { } catch {
if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr { if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr {
m.tokenStatus = .expired m.tokenStatus = .expired
} }
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")

View file

@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}

View file

@ -1,227 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
id="Path"></path>
<path
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
id="Combined-Shape"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
id="Path"></path>
<path
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
id="Combined-Shape"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
id="Path"></path>
<path
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}

View file

@ -1,218 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.wide</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
id="Path"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
id="Path"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
id="Path"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_logo_blue_white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_logo_blue.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_symbol_blue-white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,23 +0,0 @@
{
"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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -9,21 +9,10 @@ import SwiftUI
import Intents import Intents
import SimpleXChat import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
var id: String {
switch self {
case .whatsNew: return "whatsNew"
}
}
}
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate @EnvironmentObject var sceneDelegate: SceneDelegate
@ -40,15 +29,14 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false @AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var noticesShown = false @State private var showSettings = false
@State private var noticesSheetItem: NoticesSheet? = nil @State private var showWhatsNew = false
@State private var showChooseLAMode = false @State private var showChooseLAMode = false
@State private var showSetPasscode = false @State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true @State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40 private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@ -74,7 +62,7 @@ struct ContentView: View {
} }
} }
func allViews() -> some View { @ViewBuilder func allViews() -> some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted 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. // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@ -98,7 +86,7 @@ struct ContentView: View {
callView(call) callView(call)
} }
if chatListUserPickerSheet == nil, let la = chatModel.laRequest { if !showSettings, let la = chatModel.laRequest {
LocalAuthView(authRequest: la) LocalAuthView(authRequest: la)
.onDisappear { .onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication // this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
@ -121,6 +109,9 @@ struct ContentView: View {
} }
} }
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.sheet(isPresented: $showSettings) {
SettingsView(showSettings: $showSettings)
}
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) { .confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
Button("System authentication") { initialEnableLA() } Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true } Button("Passcode entry") { showSetPasscode = true }
@ -160,12 +151,12 @@ struct ContentView: View {
} }
} }
.onAppear { .onAppear {
reactOnDarkThemeChanges(systemInDarkThemeCurrently) reactOnDarkThemeChanges()
} }
.onChange(of: colorScheme) { scheme in .onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background, // It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it // so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges(scheme == .dark) reactOnDarkThemeChanges()
} }
.onChange(of: theme.name) { _ in .onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle() ThemeManager.adjustWindowStyle()
@ -209,14 +200,14 @@ struct ContentView: View {
} }
} }
private func activeCallInteractiveArea(_ call: Call) -> some View { @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack { HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white) Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer() Spacer()
CallDuration(call: call) CallDuration(call: call)
} }
.padding(.horizontal) .padding(.horizontal)
.frame(height: callTopPadding) .frame(height: callTopPadding - 10)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1))) .background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture { .onTapGesture {
chatModel.activeCallViewIsCollapsed = false chatModel.activeCallViewIsCollapsed = false
@ -262,8 +253,7 @@ struct ContentView: View {
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet) ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear { .onAppear {
requestNtfAuthorization() requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete // Local Authentication notice is to be shown on next start after onboarding is complete
@ -272,31 +262,17 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert()) alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil { } else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !noticesShown { if !showWhatsNew {
let showWhatsNew = shouldShowWhatsNew() showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
}
} }
} }
} }
prefShowLANotice = true prefShowLANotice = true
connectViaUrl() connectViaUrl()
showReRegisterTokenAlert()
} }
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() } .sheet(isPresented: $showWhatsNew) {
.sheet(item: $noticesSheetItem) { item in WhatsNewView()
switch item {
case let .whatsNew(updatedConditions):
WhatsNewView(updatedConditions: updatedConditions)
.modifier(ThemedBackground())
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
}
} }
if chatModel.setDeliveryReceipts { if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView() SetDeliveryReceiptsView()
@ -306,21 +282,6 @@ struct ContentView: View {
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
if let url = userActivity.webpageURL {
logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)")
chatModel.appOpenUrl = url
}
}
}
private func setConditionsNotified_() async {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
try await setConditionsNotified(conditionsId: conditionsId)
} catch let error {
logger.error("setConditionsNotified error: \(responseError(error))")
}
} }
private func processUserActivity(_ activity: NSUserActivity) { private func processUserActivity(_ activity: NSUserActivity) {
@ -339,18 +300,9 @@ struct ContentView: View {
if let contactId = contacts?.first?.personHandle?.value, if let contactId = contacts?.first?.personHandle?.value,
let chat = chatModel.getChat(contactId), let chat = chatModel.getChat(contactId),
case let .direct(contact) = chat.chatInfo { case let .direct(contact) = chat.chatInfo {
let activeCall = chatModel.activeCall logger.debug("callToRecentContact: schedule call")
// This line works when a user clicks on a video button in CallKit UI while in call. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken CallController.shared.startCall(contact, mediaType)
if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
Task {
await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
}
} else if activeCall == nil {
logger.debug("callToRecentContact: schedule call")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
CallController.shared.startCall(contact, mediaType)
}
} }
} }
} }
@ -443,12 +395,12 @@ struct ContentView: View {
} }
func connectViaUrl() { func connectViaUrl() {
let m = ChatModel.shared dismissAllSheets() {
if let url = m.appOpenUrl { let m = ChatModel.shared
m.appOpenUrl = nil if let url = m.appOpenUrl {
dismissAllSheets() { m.appOpenUrl = nil
var path = url.path var path = url.path
if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { if (path == "/contact" || path == "/invitation") {
path.removeFirst() path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
planAndConnect( planAndConnect(
@ -465,21 +417,6 @@ struct ContentView: View {
} }
} }
func showReRegisterTokenAlert() {
dismissAllSheets() {
let m = ChatModel.shared
if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken {
chatModel.reRegisterTknStatus = nil
AlertManager.shared.showAlert(Alert(
title: Text("Notifications error"),
message: Text(tokenStatusInfo(errorTknStatus, register: true)),
primaryButton: .default(Text("Register")) { reRegisterToken(token: token) },
secondaryButton: .cancel()
))
}
}
}
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -26,37 +26,20 @@ enum NtfCallAction {
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager() static let shared = NtfManager()
public var navigatingToChat = false
private var granted = false private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:] private var prevNtfTime: Dictionary<ChatId, Date> = [:]
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
// Handle notification when app is in background // Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) { withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive") logger.debug("NtfManager.userNotificationCenter: didReceive")
if appStateGroupDefault.get() == .active { let content = response.notification.request.content
processNotificationResponse(response)
} else {
logger.debug("NtfManager.userNotificationCenter: remember response in model")
ChatModel.shared.notificationResponse = response
}
handler()
}
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let content = ntfResponse.notification.request.content let action = response.actionIdentifier
let action = ntfResponse.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64, if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId { userId != chatModel.currentUser?.userId {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil) changeActiveUser(userId, viewPwd: nil)
} }
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@ -74,13 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction) chatModel.ntfCallInvitationAction = (chatId, ntfAction)
} }
} else { } else {
if let chatId = content.targetContentIdentifier { chatModel.chatId = content.targetContentIdentifier
self.navigatingToChat = true
ItemsModel.shared.loadOpenChat(chatId) {
self.navigatingToChat = false
}
}
} }
handler()
} }
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? { private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@ -95,6 +74,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil return nil
} }
// Handle notification when the app is in foreground // Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
@ -203,12 +183,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [], actions: [],
intentIdentifiers: [], intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification") hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
),
UNNotificationCategory(
identifier: ntfCategoryManyEvents,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
) )
]) ])
} }
@ -234,28 +208,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
} }
} }
} }
center.delegate = self
} }
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest") logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest, 0)) addNotification(createContactRequestNtf(user, contactRequest))
} }
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) { func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected") logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact, 0)) addNotification(createContactConnectedNtf(user, contact))
} }
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived") logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled(chatItem: cItem) { if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) addNotification(createMessageReceivedNtf(user, cInfo, cItem))
} }
} }
func notifyCallInvitation(_ invitation: RcvCallInvitation) { func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation") logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation, 0)) addNotification(createCallInvitationNtf(invitation))
} }
func setNtfBadgeCount(_ count: Int) { func setNtfBadgeCount(_ count: Int) {
@ -263,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count) ntfBadgeCountGroupDefault.set(count)
} }
func changeNtfBadgeCount(by count: Int = 1) { func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
} }
private func addNotification(_ content: UNMutableNotificationContent) { private func addNotification(_ content: UNMutableNotificationContent) {

File diff suppressed because it is too large Load diff

View file

@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
} }
} }
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() { func suspendChat() {
suspendLockQueue.sync { suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout) _suspendChat(timeout: appSuspendTimeout)

View file

@ -19,7 +19,6 @@ struct SimpleXApp: App {
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil @State private var enteredBackgroundAuthenticated: TimeInterval? = nil
@State private var appOpenUrlLater: URL?
init() { init() {
DispatchQueue.global(qos: .background).sync { DispatchQueue.global(qos: .background).sync {
@ -43,11 +42,7 @@ struct SimpleXApp: App {
.environmentObject(AppTheme.shared) .environmentObject(AppTheme.shared)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
if AppChatState.shared.value == .active { chatModel.appOpenUrl = url
chatModel.appOpenUrl = url
} else {
appOpenUrlLater = url
}
} }
.onAppear() { .onAppear() {
// Present screen for continue migration if it wasn't finished yet // Present screen for continue migration if it wasn't finished yet
@ -63,7 +58,6 @@ struct SimpleXApp: App {
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) { switch (phase) {
case .background: case .background:
// --- authentication // --- authentication
@ -87,27 +81,10 @@ struct SimpleXApp: App {
if appState != .stopped { if appState != .stopped {
startChatAndActivate { startChatAndActivate {
if chatModel.chatRunning == true { if appState.inactive && chatModel.chatRunning == true {
if let ntfResponse = chatModel.notificationResponse { updateChats()
chatModel.notificationResponse = nil if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
NtfManager.shared.processNotificationResponse(ntfResponse) updateCallInvitations()
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
if let url = appOpenUrlLater {
await MainActor.run {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
}
} else if let url = appOpenUrlLater {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
} }
} }
} }
@ -152,17 +129,16 @@ struct SimpleXApp: App {
} }
} }
private func updateChats() async { private func updateChats() {
do { do {
let chats = try await apiGetChatsAsync() let chats = try apiGetChats()
await MainActor.run { chatModel.updateChats(chats) } chatModel.updateChats(with: chats)
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id), let chat = chatModel.getChat(id) {
!NtfManager.shared.navigatingToChat { loadChat(chat: chat)
Task { await loadChat(chat: chat, clearItems: false) }
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil } chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
} }
@ -172,9 +148,9 @@ struct SimpleXApp: App {
} }
} }
private func updateCallInvitations() async { private func updateCallInvitations() {
do { do {
try await refreshCallInvitations() try refreshCallInvitations()
} catch let error { } catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))") logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
} }

View file

@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark return UITraitCollection.current.userInterfaceStyle == .dark
} }
func reactOnDarkThemeChanges(_ inDarkNow: Bool) { func reactOnDarkThemeChanges() {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow { if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
// Change active colors from light to dark and back based on system theme // Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
} }
@ -102,7 +102,7 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper { public func importFromString() -> ThemeWallpaper {
if preset == nil, let image { if preset == nil, let image {
// Need to save image from string and to save its path // Need to save image from string and to save its path
if let parsed = imageFromBase64(image), if let parsed = UIImage(base64Encoded: image),
let filename = saveWallpaperFile(image: parsed) { let filename = saveWallpaperFile(image: parsed) {
var copy = self var copy = self
copy.image = nil copy.image = nil
@ -122,7 +122,7 @@ extension ThemeWallpaper {
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil } let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } } let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil } let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil } let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper ( return ThemeWallpaper (
preset: preset, preset: preset,
scale: scale, scale: scale,

View file

@ -53,7 +53,7 @@ class ThemeManager {
return perUserTheme return perUserTheme
} }
let defaultTheme = defaultActiveTheme(appSettingsTheme) let defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil)) return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
} }
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme { static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
@ -197,7 +197,7 @@ class ThemeManager {
var themeIds = currentThemeIdsDefault.get() var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds) currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get()) applyTheme(nonSystemThemeName)
} }
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool { static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {

View file

@ -17,8 +17,8 @@ struct ActiveCallView: View {
@ObservedObject var call: Call @ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil @State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil @State private var localRendererAspectRatio: CGFloat? = nil
@State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
@Binding var canConnectCall: Bool @Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark @State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false @State var pipShown = false
@ -27,39 +27,24 @@ struct ActiveCallView: View {
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
if let client = client, call.hasVideo { if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
GeometryReader { g in GeometryReader { g in
let width = g.size.width * 0.3 let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
.cornerRadius(10)
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
.padding([.top, .trailing], 17)
ZStack(alignment: .center) { ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work) // For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001)) .background(Color.primary.opacity(0.000001))
CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
.onTapGesture {
remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
}
Group {
let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
if localVideoTrack != nil {
CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
.onDisappear {
localRendererAspectRatio = nil
}
} else {
Rectangle().fill(.black)
}
}
.cornerRadius(10)
.frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
.padding([.top, .trailing], 17)
} }
} }
} }
if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) { if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
ActiveCallOverlay(call: call, client: client) ActiveCallOverlay(call: call, client: client)
} }
} }
@ -69,9 +54,6 @@ struct ActiveCallView: View {
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true) AppDelegate.keepScreenOn(true)
Task {
await askRequiredPermissions()
}
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
hideKeyboard() hideKeyboard()
@ -102,7 +84,7 @@ struct ActiveCallView: View {
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { if client == nil && canConnectCall {
client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
Task { Task {
await m.callCommand.setClient(client) await m.callCommand.setClient(client)
} }
@ -117,7 +99,7 @@ struct ActiveCallView: View {
logger.debug("ActiveCallView: response \(msg.resp.respType)") logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp { switch msg.resp {
case let .capabilities(capabilities): case let .capabilities(capabilities):
let callType = CallType(media: call.initialCallType, capabilities: capabilities) let callType = CallType(media: call.localMedia, capabilities: capabilities)
Task { Task {
do { do {
try await apiSendCallInvitation(call.contact, callType) try await apiSendCallInvitation(call.contact, callType)
@ -128,7 +110,7 @@ struct ActiveCallView: View {
call.callState = .invitationSent call.callState = .invitationSent
call.localCapabilities = capabilities call.localCapabilities = capabilities
} }
if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() { if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} }
CallSoundsPlayer.shared.startConnectingCallSound() CallSoundsPlayer.shared.startConnectingCallSound()
@ -138,7 +120,7 @@ struct ActiveCallView: View {
Task { Task {
do { do {
try await apiSendCallOffer(call.contact, offer, iceCandidates, try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.initialCallType, capabilities: capabilities) media: call.localMedia, capabilities: capabilities)
} catch { } catch {
logger.error("apiSendCallOffer \(responseError(error))") logger.error("apiSendCallOffer \(responseError(error))")
} }
@ -182,9 +164,6 @@ struct ActiveCallView: View {
} }
if state.connectionState == "closed" { if state.connectionState == "closed" {
closeCallView(client) closeCallView(client)
if let callUUID = m.activeCall?.callUUID {
CallController.shared.endCall(callUUID: callUUID)
}
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false m.activeCallViewIsCollapsed = false
} }
@ -203,18 +182,10 @@ struct ActiveCallView: View {
CallSoundsPlayer.shared.vibrate(long: false) CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true wasConnected = true
} }
case let .peerMedia(source, enabled):
switch source {
case .mic: call.peerMediaSources.mic = enabled
case .camera: call.peerMediaSources.camera = enabled
case .screenAudio: call.peerMediaSources.screenAudio = enabled
case .screenVideo: call.peerMediaSources.screenVideo = enabled
case .unknown: ()
}
case .ended: case .ended:
closeCallView(client) closeCallView(client)
call.callState = .ended call.callState = .ended
if let uuid = call.callUUID { if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid) CallController.shared.endCall(callUUID: uuid)
} }
case .ok: case .ok:
@ -243,38 +214,16 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil ChatReceiver.shared.messagesChannel = nil
return return
} }
if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg, if case let .chatItemStatusUpdated(_, msg) = msg,
chatItems.contains(where: { ci in msg.chatInfo.id == call.contact.id,
ci.chatInfo.id == call.contact.id && case .sndCall = msg.chatItem.content,
ci.chatItem.content.isSndCall && case .sndRcvd = msg.chatItem.meta.itemStatus {
ci.chatItem.meta.itemStatus.isSndRcvd
}) {
CallSoundsPlayer.shared.startInCallSound() CallSoundsPlayer.shared.startInCallSound()
ChatReceiver.shared.messagesChannel = nil ChatReceiver.shared.messagesChannel = nil
} }
} }
} }
private func askRequiredPermissions() async {
let mic = await WebRTCClient.isAuthorized(for: .audio)
await MainActor.run {
call.localMediaSources.mic = mic
}
let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
var camera = call.initialCallType == .audio || cameraAuthorized
if call.initialCallType == .video && !cameraAuthorized {
camera = await WebRTCClient.isAuthorized(for: .video)
await MainActor.run {
if camera, let client {
client.setCameraEnabled(true)
}
}
}
if !mic || !camera {
WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
}
}
private func closeCallView(_ client: WebRTCClient) { private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil { if m.activeCall != nil {
m.showCallView = false m.showCallView = false
@ -290,16 +239,44 @@ struct ActiveCallOverlay: View {
var body: some View { var body: some View {
VStack { VStack {
switch call.hasVideo { switch call.localMedia {
case true: case .video:
videoCallInfoView(call) videoCallInfoView(call)
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding(.horizontal) .padding()
// Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
// Otherwise, bigger padding is added by SwiftUI when switching call types Spacer()
.padding(.vertical, 10)
case false: HStack {
toggleAudioButton()
Spacer()
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
toggleSpeakerButton()
.frame(width: 40, height: 40)
} else if call.hasMedia {
AudioDevicePicker()
.scaleEffect(2)
.frame(maxWidth: 40, maxHeight: 40)
} else {
Color.clear.frame(width: 40, height: 40)
}
Spacer()
endCallButton()
Spacer()
if call.videoEnabled {
flipCameraButton()
} else {
Color.clear.frame(width: 40, height: 40)
}
Spacer()
toggleVideoButton()
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .center)
case .audio:
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
Button { Button {
chatModel.activeCallViewIsCollapsed = true chatModel.activeCallViewIsCollapsed = true
@ -314,32 +291,35 @@ struct ActiveCallOverlay: View {
} }
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding(.horizontal) .padding()
.padding(.vertical, 10)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
}
Spacer() Spacer()
HStack { ZStack(alignment: .bottom) {
toggleMicButton() toggleAudioButton()
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
audioDeviceButton() endCallButton()
Spacer() // Check if the only input is microphone. And in this case show toggle button,
endCallButton() // If there are more inputs, it probably means something like bluetooth headphones are available
Spacer() // and in this case show iOS button for choosing different output.
if call.localMediaSources.camera { // There is no way to get available outputs, only inputs
flipCameraButton() if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
} else { toggleSpeakerButton()
Color.clear.frame(width: 60, height: 60) .frame(maxWidth: .infinity, alignment: .trailing)
} else if call.hasMedia {
AudioDevicePicker()
.scaleEffect(2)
.frame(maxWidth: 50, maxHeight: 40)
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
Color.clear.frame(width: 50, height: 40)
}
} }
Spacer() .padding(.bottom, 60)
toggleCameraButton() .padding(.horizontal, 48)
} }
.padding(.horizontal, 20)
.padding(.bottom, 16)
.frame(maxWidth: 440, alignment: .center)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onAppear { .onAppear {
@ -361,7 +341,7 @@ struct ActiveCallOverlay: View {
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") Text("(") + Text(connInfo.text) + Text(")")
} }
} }
} }
@ -390,7 +370,7 @@ struct ActiveCallOverlay: View {
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") Text("(") + Text(connInfo.text) + Text(")")
} }
} }
} }
@ -401,54 +381,36 @@ struct ActiveCallOverlay: View {
private func endCallButton() -> some View { private func endCallButton() -> some View {
let cc = CallController.shared let cc = CallController.shared
return callButton("phone.down.fill", .red, padding: 10) { return callButton("phone.down.fill", width: 60, height: 60) {
if let uuid = call.callUUID { if let uuid = call.callkitUUID {
cc.endCall(callUUID: uuid) cc.endCall(callUUID: uuid)
} else { } else {
cc.endCall(call: call) {} cc.endCall(call: call) {}
} }
} }
.foregroundColor(.red)
} }
private func toggleMicButton() -> some View { private func toggleAudioButton() -> some View {
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) { controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .audio) { client.setAudioEnabled(!call.audioEnabled)
client.setAudioEnabled(!call.localMediaSources.mic) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) } call.audioEnabled = !call.audioEnabled
} }
}
}
func audioDeviceButton() -> some View {
// Check if the only input is microphone. And in this case show toggle button,
// If there are more inputs, it probably means something like bluetooth headphones are available
// and in this case show iOS button for choosing different output.
// There is no way to get available outputs, only inputs
Group {
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
toggleSpeakerButton()
} else {
audioDevicePickerButton()
}
}
.onChange(of: call.localMediaSources.hasVideo) { hasVideo in
let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
let speakerEnabled = current == .builtInSpeaker
let receiverEnabled = current == .builtInReceiver
// react automatically only when receiver were selected, otherwise keep an external device selected
if !speakerEnabled && hasVideo && receiverEnabled {
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
call.speakerEnabled = !speakerEnabled
} }
} }
} }
private func toggleSpeakerButton() -> some View { private func toggleSpeakerButton() -> some View {
controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) { controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker Task {
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled) let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
call.speakerEnabled = !speakerEnabled client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
DispatchQueue.main.async {
call.speakerEnabled = !speakerEnabled
}
}
} }
.onAppear { .onAppear {
deviceManager.call = call deviceManager.call = call
@ -456,67 +418,53 @@ struct ActiveCallOverlay: View {
} }
} }
private func toggleCameraButton() -> some View { private func toggleVideoButton() -> some View {
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) { controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { client.setVideoEnabled(!call.videoEnabled)
client.setCameraEnabled(!call.localMediaSources.camera) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .video) } call.videoEnabled = !call.videoEnabled
}
} }
} }
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
} }
private func flipCameraButton() -> some View { @ViewBuilder private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) { controlButton(call, "arrow.triangle.2.circlepath") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { client.flipCamera()
client.flipCamera()
}
} }
} }
} }
private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) if call.hasMedia {
callButton(imageName, width: 50, height: 38, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 50, height: 38)
}
} }
private func audioDevicePickerButton() -> some View { private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
AudioDevicePicker()
.opacity(0.8)
.scaleEffect(2)
.padding(10)
.frame(width: 60, height: 60)
.background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
.clipShape(.circle)
}
private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button { Button {
perform() perform()
} label: { } label: {
Image(systemName: imageName) Image(systemName: imageName)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.padding(padding) .frame(maxWidth: width, maxHeight: height)
.frame(width: 60, height: 60)
.background(background)
} }
.foregroundColor(whiteColorWithAlpha)
.clipShape(.circle)
}
private var whiteColorWithAlpha: Color {
get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
} }
} }
struct ActiveCallOverlay_Previews: PreviewProvider { struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil))) ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black) .background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil))) ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black) .background(.black)
} }
} }

View file

@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) { func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction") logger.debug("CallController.provider CXStartCallAction")
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) { if callManager.startOutgoingCall(callUUID: action.callUUID) {
action.fulfill() action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else { } else {
@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction") logger.debug("CallController.provider CXAnswerCallAction")
Task { if callManager.answerIncomingCall(callUUID: action.callUUID) {
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500) // WebRTC call should be in connected state to fulfill.
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))") // Otherwise no audio and mic working on lockscreen
if !chatIsReady { fulfillOnConnect = action
action.fail() } else {
return action.fail()
}
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
try? await justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
}
await MainActor.run {
logger.debug("CallController.provider will answer on call")
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
logger.debug("CallController.provider answered on call")
// WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action
} else {
logger.debug("CallController.provider will fail the call")
action.fail()
}
}
} }
} }
@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state // Should be nil here if connection was in connected state
fulfillOnConnect?.fail() fulfillOnConnect?.fail()
fulfillOnConnect = nil fulfillOnConnect = nil
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in callManager.endCall(callUUID: action.callUUID) { ok in
if ok { if ok {
action.fulfill() action.fulfill()
} else { } else {
@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) { if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
action.fulfill() action.fulfill()
} else { } else {
action.fail() action.fail()
@ -121,8 +103,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true RTCAudioSession.sharedInstance().isAudioEnabled = true
do { do {
let hasVideo = ChatModel.shared.activeCall?.hasVideo == true let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
if hasVideo { if supportsVideo {
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} else { } else {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
@ -133,7 +115,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000) try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
if let preferred = audioSession.preferredInputDevice() { if let preferred = audioSession.preferredInputDevice() {
await MainActor.run { try? audioSession.setPreferredInput(preferred) } await MainActor.run { try? audioSession.setPreferredInput(preferred) }
} else if hasVideo { } else if supportsVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) } await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
} }
} }
@ -174,19 +156,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
} }
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
logger.debug("CallController waiting until chat started")
var t: UInt64 = 0
repeat {
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
return true
}
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
} while t < timeoutMs
return false
}
@objc(pushRegistry:didUpdatePushCredentials:forType:) @objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)") logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
@ -202,19 +171,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
self.reportExpiredCall(payload: payload, completion) self.reportExpiredCall(payload: payload, completion)
return return
} }
if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
self.reportExpiredCall(payload: payload, completion)
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE
_ = try? justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload // Extract the call information from the push notification payload
let m = ChatModel.shared let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String, if let contactId = payload.dictionaryPayload["contactId"] as? String,
let displayName = payload.dictionaryPayload["displayName"] as? String, let invitation = m.callInvitations[contactId] {
let callUUID = payload.dictionaryPayload["callUUID"] as? String, let update = self.cxCallUpdate(invitation: invitation)
let uuid = UUID(uuidString: callUUID), if let uuid = invitation.callkitUUID {
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
let mediaStr = payload.dictionaryPayload["media"] as? String,
let media = CallMediaType(rawValue: mediaStr) {
let update = self.cxCallUpdate(contactId, displayName, media)
let callTs = Date(timeIntervalSince1970: callTsInterval)
if callTs.timeIntervalSinceNow >= -180 {
logger.debug("CallController: report pushkit call via CallKit") logger.debug("CallController: report pushkit call via CallKit")
let update = self.cxCallUpdate(invitation: invitation)
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil { if error != nil {
m.callInvitations.removeValue(forKey: contactId) m.callInvitations.removeValue(forKey: contactId)
@ -223,31 +205,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion() completion()
} }
} else { } else {
logger.debug("CallController will expire call 1")
self.reportExpiredCall(update: update, completion) self.reportExpiredCall(update: update, completion)
} }
} else { } else {
logger.debug("CallController will expire call 2")
self.reportExpiredCall(payload: payload, completion) self.reportExpiredCall(payload: payload, completion)
} }
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
if let call = ChatModel.shared.activeCall {
self.endCall(call: call, completed: completion)
}
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
} }
// This function fulfils the requirement to always report a call when PushKit notification is received, // This function fulfils the requirement to always report a call when PushKit notification is received,
@ -277,8 +239,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))") logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 { if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@ -299,14 +261,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update return update
} }
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: contactId)
update.hasVideo = media == .video
update.localizedCallerName = displayName
return update
}
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected") logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() { if CallController.useCallKit() {
@ -318,14 +272,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected") logger.debug("CallController: reporting outgoing call connected")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
} }
} }
func reportCallRemoteEnded(invitation: RcvCallInvitation) { func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended") logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id { } else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil activeCallInvitation = nil
@ -334,17 +288,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) { func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended") logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} }
} }
func startCall(_ contact: Contact, _ media: CallMediaType) { func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall") logger.debug("CallController.startCall")
let callUUID = callManager.newOutgoingCall(contact, media) let uuid = callManager.newOutgoingCall(contact, media)
guard let uuid = UUID(uuidString: callUUID) else {
return
}
if CallController.useCallKit() { if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id) let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle) let action = CXStartCallAction(call: uuid, handle: handle)
@ -356,17 +307,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update) self.provider.reportCall(with: uuid, updated: update)
} }
} else if callManager.startOutgoingCall(callUUID: callUUID) { } else if callManager.startOutgoingCall(callUUID: uuid) {
logger.debug("CallController.startCall: call started") if callManager.startOutgoingCall(callUUID: uuid) {
} else { logger.debug("CallController.startCall: call started")
logger.error("CallController.startCall: no active call") } else {
logger.error("CallController.startCall: no active call")
}
} }
} }
func answerCall(invitation: RcvCallInvitation) { func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call") logger.debug("CallController: answering a call")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: uuid)) requestTransaction(with: CXAnswerCallAction(call: callUUID))
} else { } else {
callManager.answerIncomingCall(invitation: invitation) callManager.answerIncomingCall(invitation: invitation)
} }
@ -375,13 +328,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
} }
func endCall(callUUID: String) { func endCall(callUUID: UUID) {
let uuid = UUID(uuidString: callUUID) logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() { if CallController.useCallKit() {
if let uuid { requestTransaction(with: CXEndCallAction(call: callUUID))
requestTransaction(with: CXEndCallAction(call: uuid))
}
} else { } else {
callManager.endCall(callUUID: callUUID) { ok in callManager.endCall(callUUID: callUUID) { ok in
if ok { if ok {

View file

@ -10,25 +10,25 @@ import Foundation
import SimpleXChat import SimpleXChat
class CallManager { class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String { func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID().uuidString.lowercased() let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media) let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
call.speakerEnabled = media == .video call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call ChatModel.shared.activeCall = call
return uuid return uuid
} }
func startOutgoingCall(callUUID: String) -> Bool { func startOutgoingCall(callUUID: UUID) -> Bool {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) } Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true return true
} }
return false return false
} }
func answerIncomingCall(callUUID: String) -> Bool { func answerIncomingCall(callUUID: UUID) -> Bool {
if let invitation = getCallInvitation(callUUID) { if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation) answerIncomingCall(invitation: invitation)
return true return true
@ -42,9 +42,9 @@ class CallManager {
let call = Call( let call = Call(
direction: .incoming, direction: .incoming,
contact: invitation.contact, contact: invitation.contact,
callUUID: invitation.callUUID, callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted, callState: .invitationAccepted,
initialCallType: invitation.callType.media, localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey sharedKey: invitation.sharedKey
) )
call.speakerEnabled = invitation.callType.media == .video call.speakerEnabled = invitation.callType.media == .video
@ -68,17 +68,17 @@ class CallManager {
} }
} }
func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) } Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
return true return true
} }
return false return false
} }
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) { func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
endCall(call: call) { completed(true) } endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) { } else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) } endCall(invitation: invitation) { completed(true) }
@ -126,8 +126,8 @@ class CallManager {
} }
} }
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? { private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) { if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation return invitation
} }
return nil return nil

View file

@ -10,49 +10,40 @@ import AVKit
struct CallViewRemote: UIViewRepresentable { struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
@ObservedObject var call: Call var activeCall: Binding<WebRTCClient.Call?>
@State var enablePip: (Bool) -> Void = {_ in } @State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool @Binding var activeCallViewIsCollapsed: Bool
@Binding var contentMode: UIView.ContentMode
@Binding var pipShown: Bool @Binding var pipShown: Bool
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
self.client = client
self.activeCall = activeCall
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
let view = UIView() let view = UIView()
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame) if let call = activeCall.wrappedValue {
remoteCameraRenderer.videoContentMode = contentMode let remoteRenderer = RTCMTLVideoView(frame: view.frame)
remoteCameraRenderer.tag = 0 remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer)
addSubviewAndResize(remoteRenderer, into: view)
let screenVideo = call.peerMediaSources.screenVideo if AVPictureInPictureController.isPictureInPictureSupported() {
let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame) makeViewWithRTCRenderer(call, remoteRenderer, view, context)
remoteScreenRenderer.videoContentMode = contentMode }
remoteScreenRenderer.tag = 1
remoteScreenRenderer.alpha = screenVideo ? 1 : 0
context.coordinator.cameraRenderer = remoteCameraRenderer
context.coordinator.screenRenderer = remoteScreenRenderer
client.addRemoteCameraRenderer(remoteCameraRenderer)
client.addRemoteScreenRenderer(remoteScreenRenderer)
if screenVideo {
addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
} else {
addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
}
if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
} }
return view return view
} }
func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) { func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame) let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill pipRemoteRenderer.videoContentMode = .scaleAspectFill
let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController() let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920) pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
let pipContentSource = AVPictureInPictureController.ContentSource( let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view, activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController contentViewController: pipVideoCallViewController
@ -64,9 +55,7 @@ struct CallViewRemote: UIViewRepresentable {
context.coordinator.pipController = pipController context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in context.coordinator.willShowHide = { show in
if show { if show {
client.addRemoteCameraRenderer(pipRemoteCameraRenderer) client.addRemoteRenderer(call, pipRemoteRenderer)
client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
context.coordinator.relayout()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true activeCallViewIsCollapsed = true
} }
@ -78,29 +67,13 @@ struct CallViewRemote: UIViewRepresentable {
} }
context.coordinator.didShowHide = { show in context.coordinator.didShowHide = { show in
if show { if show {
remoteCameraRenderer.isHidden = true remoteRenderer.isHidden = true
remoteScreenRenderer.isHidden = true
} else { } else {
client.removeRemoteCameraRenderer(pipRemoteCameraRenderer) client.removeRemoteRenderer(call, pipRemoteRenderer)
client.removeRemoteScreenRenderer(pipRemoteScreenRenderer) remoteRenderer.isHidden = false
remoteCameraRenderer.isHidden = false
remoteScreenRenderer.isHidden = false
} }
pipShown = show pipShown = show
} }
context.coordinator.relayout = {
let camera = call.peerMediaSources.camera
let screenVideo = call.peerMediaSources.screenVideo
pipRemoteCameraRenderer.alpha = camera ? 1 : 0
pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
if screenVideo {
addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
} else {
addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
}
(pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
(pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
}
DispatchQueue.main.async { DispatchQueue.main.async {
enablePip = { enable in enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ { if enable != pipShown /* pipController.isPictureInPictureActive */ {
@ -115,50 +88,24 @@ struct CallViewRemote: UIViewRepresentable {
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(client) Coordinator()
} }
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote") logger.debug("CallView.updateUIView remote")
let camera = view.subviews.first(where: { $0.tag == 0 })!
let screen = view.subviews.first(where: { $0.tag == 1 })!
let screenVideo = call.peerMediaSources.screenVideo
if screenVideo && screen.alpha == 0 {
screen.alpha = 1
addSubviewAndResize(screen, camera, into: view)
} else if !screenVideo && screen.alpha == 1 {
screen.alpha = 0
addSubviewAndResize(camera, screen, into: view)
}
(view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
(view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
camera.alpha = call.peerMediaSources.camera ? 1 : 0
screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
DispatchQueue.main.async { DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown { if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed) enablePip(activeCallViewIsCollapsed)
} else if pipShown {
context.coordinator.relayout()
} }
} }
} }
// MARK: - Coordinator // MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate { class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var cameraRenderer: RTCMTLVideoView?
var screenRenderer: RTCMTLVideoView?
var client: WebRTCClient
var pipController: AVPictureInPictureController? = nil var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in } var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in } var didShowHide: (Bool) -> Void = { _ in }
var relayout: () -> Void = {}
required init(_ client: WebRTCClient) {
self.client = client
}
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true) willShowHide(true)
} }
@ -180,20 +127,11 @@ struct CallViewRemote: UIViewRepresentable {
} }
deinit { deinit {
// TODO: deinit is not called when changing call type from audio to video and back,
// which causes many renderers can be created and added to stream (if enabling/disabling
// video while not yet connected in outgoing call)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil pipController?.contentSource = nil
pipController?.delegate = nil pipController?.delegate = nil
pipController = nil pipController = nil
if let cameraRenderer {
client.removeRemoteCameraRenderer(cameraRenderer)
}
if let screenRenderer {
client.removeRemoteScreenRenderer(screenRenderer)
}
} }
} }
@ -210,109 +148,51 @@ struct CallViewRemote: UIViewRepresentable {
struct CallViewLocal: UIViewRepresentable { struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?>
var localRendererAspectRatio: Binding<CGFloat?> var localRendererAspectRatio: Binding<CGFloat?>
@State var pipStateChanged: (Bool) -> Void = {_ in } @State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool @Binding var pipShown: Bool
init(client: WebRTCClient, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
self.client = client self.client = client
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
let view = UIView() let view = UIView()
let localRenderer = RTCEAGLVideoView(frame: .zero) if let call = activeCall.wrappedValue {
context.coordinator.renderer = localRenderer let localRenderer = RTCEAGLVideoView(frame: .zero)
client.addLocalRenderer(localRenderer) client.addLocalRenderer(call, localRenderer)
addSubviewAndResize(localRenderer, nil, into: view) client.startCaptureLocalVideo(call)
DispatchQueue.main.async { addSubviewAndResize(localRenderer, into: view)
pipStateChanged = { shown in DispatchQueue.main.async {
localRenderer.isHidden = shown pipStateChanged = { shown in
localRenderer.isHidden = shown
}
} }
} }
return view return view
} }
func makeCoordinator() -> Coordinator {
Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local") logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown) pipStateChanged(pipShown)
} }
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var renderer: RTCEAGLVideoView?
var client: WebRTCClient
required init(_ client: WebRTCClient) {
self.client = client
}
deinit {
if let renderer {
client.removeLocalRenderer(renderer)
}
}
}
} }
private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) { private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) { containerView.addSubview(view)
// Nothing to do, elements on their places view.translatesAutoresizingMaskIntoConstraints = false
return containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
}
containerView.removeConstraints(containerView.constraints)
containerView.subviews.forEach { sub in sub.removeFromSuperview()}
containerView.addSubview(fullscreen)
fullscreen.translatesAutoresizingMaskIntoConstraints = false
fullscreen.layer.cornerRadius = 0
fullscreen.layer.masksToBounds = false
if let end {
containerView.addSubview(end)
end.translatesAutoresizingMaskIntoConstraints = false
end.layer.cornerRadius = pip ? 8 : 10
end.layer.masksToBounds = true
}
let constraintFullscreenV = NSLayoutConstraint.constraints(
withVisualFormat: "V:|[fullscreen]|",
options: [], options: [],
metrics: nil, metrics: nil,
views: ["fullscreen": fullscreen] views: ["view": view]))
)
let constraintFullscreenH = NSLayoutConstraint.constraints( containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
withVisualFormat: "H:|[fullscreen]|",
options: [], options: [],
metrics: nil, metrics: nil,
views: ["fullscreen": fullscreen] views: ["view": view]))
)
containerView.addConstraints(constraintFullscreenV)
containerView.addConstraints(constraintFullscreenH)
if let end {
let constraintEndWidth = NSLayoutConstraint(
item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
)
let constraintEndHeight = NSLayoutConstraint(
item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
)
let constraintEndX = NSLayoutConstraint(
item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
)
let constraintEndY = NSLayoutConstraint(
item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
)
containerView.addConstraint(constraintEndWidth)
containerView.addConstraint(constraintEndHeight)
containerView.addConstraint(constraintEndX)
containerView.addConstraint(constraintEndY)
}
containerView.layoutIfNeeded() containerView.layoutIfNeeded()
} }

View file

@ -38,7 +38,6 @@ struct IncomingCallView: View {
} }
HStack { HStack {
ProfilePreview(profileOf: invitation.contact, color: .white) ProfilePreview(profileOf: invitation.contact, color: .white)
.padding(.vertical, 6)
Spacer() Spacer()
callButton("Reject", "phone.down.fill", .red) { callButton("Reject", "phone.down.fill", .red) {

View file

@ -18,49 +18,49 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection var direction: CallDirection
var contact: Contact var contact: Contact
var callUUID: String? var callkitUUID: UUID?
var initialCallType: CallMediaType var localMedia: CallMediaType
@Published var localMediaSources: CallMediaSources
@Published var callState: CallState @Published var callState: CallState
@Published var localCapabilities: CallCapabilities? @Published var localCapabilities: CallCapabilities?
@Published var peerMediaSources: CallMediaSources = CallMediaSources() @Published var peerMedia: CallMediaType?
@Published var sharedKey: String? @Published var sharedKey: String?
@Published var audioEnabled = true
@Published var speakerEnabled = false @Published var speakerEnabled = false
@Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo? @Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil @Published var connectedAt: Date? = nil
init( init(
direction: CallDirection, direction: CallDirection,
contact: Contact, contact: Contact,
callUUID: String?, callkitUUID: UUID?,
callState: CallState, callState: CallState,
initialCallType: CallMediaType, localMedia: CallMediaType,
sharedKey: String? = nil sharedKey: String? = nil
) { ) {
self.direction = direction self.direction = direction
self.contact = contact self.contact = contact
self.callUUID = callUUID self.callkitUUID = callkitUUID
self.callState = callState self.callState = callState
self.initialCallType = initialCallType self.localMedia = localMedia
self.sharedKey = sharedKey self.sharedKey = sharedKey
self.localMediaSources = CallMediaSources( self.videoEnabled = localMedia == .video
mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
} }
var encrypted: Bool { get { localEncrypted && sharedKey != nil } } var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } } var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey { var encryptionStatus: LocalizedStringKey {
get { get {
switch callState { switch callState {
case .waitCapabilities: return "" case .waitCapabilities: return ""
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption" case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted" default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
} }
} }
} }
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
} }
enum CallDirection { enum CallDirection {
@ -105,28 +105,18 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
var command: WCallCommand? var command: WCallCommand?
} }
struct CallMediaSources: Equatable, Codable {
var mic: Bool = false
var camera: Bool = false
var screenAudio: Bool = false
var screenVideo: Bool = false
var hasVideo: Bool { get { camera || screenVideo } }
}
enum WCallCommand: Equatable, Encodable, Decodable { enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities(media: CallMediaType) case capabilities(media: CallMediaType)
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String) case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String) case ice(iceCandidates: String)
case media(source: CallMediaSource, enable: Bool) case media(media: CallMediaType, enable: Bool)
case end case end
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case type case type
case media case media
case source
case aesKey case aesKey
case offer case offer
case answer case answer
@ -177,9 +167,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case let .ice(iceCandidates): case let .ice(iceCandidates):
try container.encode("ice", forKey: .type) try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates) try container.encode(iceCandidates, forKey: .iceCandidates)
case let .media(source, enable): case let .media(media, enable):
try container.encode("media", forKey: .type) try container.encode("media", forKey: .type)
try container.encode(source, forKey: .media) try container.encode(media, forKey: .media)
try container.encode(enable, forKey: .enable) try container.encode(enable, forKey: .enable)
case .end: case .end:
try container.encode("end", forKey: .type) try container.encode("end", forKey: .type)
@ -215,9 +205,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates) self = .ice(iceCandidates: iceCandidates)
case "media": case "media":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
self = .media(source: source, enable: enable) self = .media(media: media, enable: enable)
case "end": case "end":
self = .end self = .end
default: default:
@ -234,7 +224,6 @@ enum WCallResponse: Equatable, Decodable {
case ice(iceCandidates: String) case ice(iceCandidates: String)
case connection(state: ConnectionState) case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo) case connected(connectionInfo: ConnectionInfo)
case peerMedia(source: CallMediaSource, enabled: Bool)
case ended case ended
case ok case ok
case error(message: String) case error(message: String)
@ -249,8 +238,6 @@ enum WCallResponse: Equatable, Decodable {
case state case state
case connectionInfo case connectionInfo
case message case message
case source
case enabled
} }
var respType: String { var respType: String {
@ -262,7 +249,6 @@ enum WCallResponse: Equatable, Decodable {
case .ice: return "ice" case .ice: return "ice"
case .connection: return "connection" case .connection: return "connection"
case .connected: return "connected" case .connected: return "connected"
case .peerMedia: return "peerMedia"
case .ended: return "ended" case .ended: return "ended"
case .ok: return "ok" case .ok: return "ok"
case .error: return "error" case .error: return "error"
@ -297,10 +283,6 @@ enum WCallResponse: Equatable, Decodable {
case "connected": case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo) let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: connectionInfo) self = .connected(connectionInfo: connectionInfo)
case "peerMedia":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
self = .peerMedia(source: source, enabled: enabled)
case "ended": case "ended":
self = .ended self = .ended
case "ok": case "ok":
@ -342,10 +324,6 @@ extension WCallResponse: Encodable {
case let .connected(connectionInfo): case let .connected(connectionInfo):
try container.encode("connected", forKey: .type) try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo) try container.encode(connectionInfo, forKey: .connectionInfo)
case let .peerMedia(source, enabled):
try container.encode("peerMedia", forKey: .type)
try container.encode(source, forKey: .source)
try container.encode(enabled, forKey: .enabled)
case .ended: case .ended:
try container.encode("ended", forKey: .type) try container.encode("ended", forKey: .type)
case .ok: case .ok:
@ -398,7 +376,7 @@ actor WebRTCCommandProcessor {
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool { func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c { switch c {
case .capabilities, .start, .offer, .end: true case .capabilities, .start, .offer, .end: true
default: client.activeCall != nil default: client.activeCall.wrappedValue != nil
} }
} }
} }

View file

@ -23,24 +23,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
struct Call { struct Call {
var connection: RTCPeerConnection var connection: RTCPeerConnection
var iceCandidates: IceCandidates var iceCandidates: IceCandidates
var localMedia: CallMediaType
var localCamera: RTCVideoCapturer? var localCamera: RTCVideoCapturer?
var localAudioTrack: RTCAudioTrack? var localVideoSource: RTCVideoSource?
var localVideoTrack: RTCVideoTrack? var localStream: RTCVideoTrack?
var remoteAudioTrack: RTCAudioTrack? var remoteStream: RTCVideoTrack?
var remoteVideoTrack: RTCVideoTrack? var device: AVCaptureDevice.Position = .front
var remoteScreenAudioTrack: RTCAudioTrack?
var remoteScreenVideoTrack: RTCVideoTrack?
var device: AVCaptureDevice.Position
var aesKey: String? var aesKey: String?
var frameEncryptor: RTCFrameEncryptor? var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor? var frameDecryptor: RTCFrameDecryptor?
var peerHasOldVersion: Bool
}
struct NotConnectedCall {
var audioTrack: RTCAudioTrack?
var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
var device: AVCaptureDevice.Position = .front
} }
actor IceCandidates { actor IceCandidates {
@ -60,20 +51,17 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
private let rtcAudioSession = RTCAudioSession.sharedInstance() private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio") private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
var activeCall: Call? var activeCall: Binding<Call?>
var notConnectedCall: NotConnectedCall?
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
var cameraRenderers: [RTCVideoRenderer] = []
var screenRenderers: [RTCVideoRenderer] = []
@available(*, unavailable) @available(*, unavailable)
override init() { override init() {
fatalError("Unimplemented") fatalError("Unimplemented")
} }
required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) { required init(_ activeCall: Binding<Call?>, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
self.sendCallResponse = sendCallResponse self.sendCallResponse = sendCallResponse
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.useManualAudio = CallController.useCallKit()
rtcAudioSession.isAudioEnabled = !CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
@ -90,45 +78,39 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self connection.delegate = self
let device = notConnectedCall?.device ?? .front createAudioSender(connection)
var localStream: RTCVideoTrack? = nil
var remoteStream: RTCVideoTrack? = nil
var localCamera: RTCVideoCapturer? = nil var localCamera: RTCVideoCapturer? = nil
var localAudioTrack: RTCAudioTrack? = nil var localVideoSource: RTCVideoSource? = nil
var localVideoTrack: RTCVideoTrack? = nil if mediaType == .video {
if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack { (localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
(localCamera, localVideoTrack) = localCameraAndTrack
} else if notConnectedCall == nil && mediaType == .video {
(localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
} }
if let audioTrack = notConnectedCall?.audioTrack {
localAudioTrack = audioTrack
} else if notConnectedCall == nil {
localAudioTrack = createAudioTrack()
}
notConnectedCall?.localCameraAndTrack = nil
notConnectedCall?.audioTrack = nil
var frameEncryptor: RTCFrameEncryptor? = nil var frameEncryptor: RTCFrameEncryptor? = nil
var frameDecryptor: RTCFrameDecryptor? = nil var frameDecryptor: RTCFrameDecryptor? = nil
if aesKey != nil { if aesKey != nil {
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes)) let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
encryptor.delegate = self encryptor.delegate = self
frameEncryptor = encryptor frameEncryptor = encryptor
connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes)) let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
decryptor.delegate = self decryptor.delegate = self
frameDecryptor = decryptor frameDecryptor = decryptor
// Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
// connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
} }
return Call( return Call(
connection: connection, connection: connection,
iceCandidates: IceCandidates(), iceCandidates: IceCandidates(),
localMedia: mediaType,
localCamera: localCamera, localCamera: localCamera,
localAudioTrack: localAudioTrack, localVideoSource: localVideoSource,
localVideoTrack: localVideoTrack, localStream: localStream,
device: device, remoteStream: remoteStream,
aesKey: aesKey, aesKey: aesKey,
frameEncryptor: frameEncryptor, frameEncryptor: frameEncryptor,
frameDecryptor: frameDecryptor, frameDecryptor: frameDecryptor
peerHasOldVersion: false
) )
} }
@ -169,24 +151,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func sendCallCommand(command: WCallCommand) async { func sendCallCommand(command: WCallCommand) async {
var resp: WCallResponse? = nil var resp: WCallResponse? = nil
let pc = activeCall?.connection let pc = activeCall.wrappedValue?.connection
switch command { switch command {
case let .capabilities(media): // outgoing case .capabilities:
let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
? createVideoTrackAndStartCapture(.front)
: nil
notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption)) resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
case let .start(media: media, aesKey, iceServers, relay): // incoming case let .start(media: media, aesKey, iceServers, relay):
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall != nil { endCall() } if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
activeCall = call activeCall.wrappedValue = call
setupLocalTracks(true, call)
let (offer, error) = await call.connection.offer() let (offer, error) = await call.connection.offer()
if let offer = offer { if let offer = offer {
setupEncryptionForLocalTracks(call)
resp = .offer( resp = .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))), offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())), iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
@ -196,24 +172,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else { } else {
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")") resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
} }
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall != nil { if activeCall.wrappedValue != nil {
resp = .error(message: "accept: call already started") resp = .error(message: "accept: call already started")
} else if !WebRTCClient.enableEncryption && aesKey != nil { } else if !WebRTCClient.enableEncryption && aesKey != nil {
resp = .error(message: "accept: encryption is not supported") resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
activeCall = call activeCall.wrappedValue = call
let pc = call.connection let pc = call.connection
if let type = offer.type, let sdp = offer.sdp { if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
setupLocalTracks(false, call)
setupEncryptionForLocalTracks(call)
pc.transceivers.forEach { transceiver in
transceiver.setDirection(.sendRecv, error: nil)
}
await adaptToOldVersion(pc.transceivers.count <= 2)
let (answer, error) = await pc.answer() let (answer, error) = await pc.answer()
if let answer = answer { if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates) self.addIceCandidates(pc, remoteIceCandidates)
@ -230,7 +200,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
} }
case let .answer(answer, iceCandidates): // incoming case let .answer(answer, iceCandidates):
if pc == nil { if pc == nil {
resp = .error(message: "answer: call not started") resp = .error(message: "answer: call not started")
} else if pc?.localDescription == nil { } else if pc?.localDescription == nil {
@ -242,9 +212,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
let type = answer.type, let sdp = answer.sdp, let type = answer.type, let sdp = answer.sdp,
let pc = pc { let pc = pc {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
var currentDirection: RTCRtpTransceiverDirection = .sendOnly
pc.transceivers[2].currentDirection(&currentDirection)
await adaptToOldVersion(currentDirection == .sendOnly)
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
resp = .ok resp = .ok
} else { } else {
@ -259,11 +226,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else { } else {
resp = .error(message: "ice: call not started") resp = .error(message: "ice: call not started")
} }
case let .media(source, enable): case let .media(media, enable):
if activeCall == nil { if activeCall.wrappedValue == nil {
resp = .error(message: "media: call not started") resp = .error(message: "media: call not started")
} else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
resp = .error(message: "media: no video")
} else { } else {
await enableMedia(source, enable) enableMedia(media, enable)
resp = .ok resp = .ok
} }
case .end: case .end:
@ -278,7 +247,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func getInitialIceCandidates() async -> [RTCIceCandidate] { func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {} await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
let candidates = await activeCall?.iceCandidates.getAndClear() ?? [] let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)") logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates return candidates
} }
@ -286,7 +255,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func waitForMoreIceCandidates() { func waitForMoreIceCandidates() {
Task { Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) { await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? [] let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 { if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)") logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates) await self.sendIceCandidates(candidates)
@ -303,202 +272,25 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
) )
} }
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
Task { media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
var lastBytesReceived: Int64 = 0
// muted initially
var mutedSeconds = 4
while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
if let stat {
//logger.debug("Stat \(stat.debugDescription)")
let bytes = stat.values["bytesReceived"] as! Int64
if bytes <= lastBytesReceived {
mutedSeconds += 1
if mutedSeconds == 3 {
await MainActor.run {
self.onMediaMuteUnmute(transceiver.mid, true)
}
}
} else {
if mutedSeconds >= 3 {
await MainActor.run {
self.onMediaMuteUnmute(transceiver.mid, false)
}
}
lastBytesReceived = bytes
mutedSeconds = 0
}
}
try? await Task.sleep(nanoseconds: 1000_000000)
}
}
} }
@MainActor func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) { activeCall.localStream?.add(renderer)
guard let activeCall = ChatModel.shared.activeCall else { return }
let source = mediaSourceFromTransceiverMid(transceiverMid)
logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
if source == .mic && activeCall.peerMediaSources.mic == mute {
activeCall.peerMediaSources.mic = !mute
} else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
activeCall.peerMediaSources.camera = !mute
} else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
activeCall.peerMediaSources.screenAudio = !mute
} else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
activeCall.peerMediaSources.screenVideo = !mute
}
}
@MainActor
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
}
@MainActor
func adaptToOldVersion(_ peerHasOldVersion: Bool) {
activeCall?.peerHasOldVersion = peerHasOldVersion
if peerHasOldVersion {
logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
onMediaMuteUnmute("0", false)
if activeCall?.remoteVideoTrack != nil {
onMediaMuteUnmute("1", false)
}
if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
logger.debug("Stopping video track for the old version")
activeCall?.connection.senders[1].track = nil
ChatModel.shared.activeCall?.localMediaSources.camera = false
(activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
activeCall?.localCamera = nil
activeCall?.localVideoTrack = nil
}
}
}
func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
if let activeCall {
if let track = activeCall.localVideoTrack {
track.add(renderer)
}
} else if let notConnectedCall {
if let track = notConnectedCall.localCameraAndTrack?.1 {
track.add(renderer)
}
}
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize) // To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
renderer.delegate = self renderer.delegate = self
} }
func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
if let activeCall {
if let track = activeCall.localVideoTrack {
track.remove(renderer)
}
} else if let notConnectedCall {
if let track = notConnectedCall.localCameraAndTrack?.1 {
track.remove(renderer)
}
}
renderer.delegate = nil
}
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
guard size.height > 0 else { return } guard size.height > 0 else { return }
localRendererAspectRatio.wrappedValue = size.width / size.height localRendererAspectRatio.wrappedValue = size.width / size.height
} }
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
let pc = call.connection
let transceivers = call.connection.transceivers
let audioTrack = call.localAudioTrack
let videoTrack = call.localVideoTrack
if incomingCall {
let micCameraInit = RTCRtpTransceiverInit()
// streamIds required for old versions which adds tracks from stream, not from track property
micCameraInit.streamIds = ["micCamera"]
let screenAudioVideoInit = RTCRtpTransceiverInit()
screenAudioVideoInit.streamIds = ["screenAudioVideo"]
// incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
// mid = 0, mic
if let audioTrack {
pc.addTransceiver(with: audioTrack, init: micCameraInit)
} else {
pc.addTransceiver(of: .audio, init: micCameraInit)
}
// mid = 1, camera
if let videoTrack {
pc.addTransceiver(with: videoTrack, init: micCameraInit)
} else {
pc.addTransceiver(of: .video, init: micCameraInit)
}
// mid = 2, screenAudio
pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
// mid = 3, screenVideo
pc.addTransceiver(of: .video, init: screenAudioVideoInit)
} else {
// new version
if transceivers.count > 2 {
// Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
transceivers
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
.sender.track = audioTrack
transceivers
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
.sender.track = videoTrack
} else {
// old version, only two transceivers
if let audioTrack {
pc.add(audioTrack, streamIds: ["micCamera"])
} else {
// it's important to have any track in order to be able to turn it on again (currently it's off)
let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
sender?.track = nil
}
if let videoTrack {
pc.add(videoTrack, streamIds: ["micCamera"])
} else {
// it's important to have any track in order to be able to turn it on again (currently it's off)
let localVideoSource = WebRTCClient.factory.videoSource()
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
sender?.track = nil
}
}
}
}
func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
switch mid {
case "0":
return .mic
case "1":
return .camera
case "2":
return .screenAudio
case "3":
return .screenVideo
default:
return .unknown
}
}
// Should be called after local description set
func setupEncryptionForLocalTracks(_ call: Call) {
if let encryptor = call.frameEncryptor {
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
}
}
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? { func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
guard encrypted.count > 0 else { return nil } guard encrypted.count > 0 else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) { let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count) memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
let isKeyFrame = encrypted[0] & 1 == 0 let isKeyFrame = encrypted[0] & 1 == 0
@ -512,7 +304,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? { func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
guard unencrypted.count > 0 else { return nil } guard unencrypted.count > 0 else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) { let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0 let isKeyFrame = unencrypted[0] & 1 == 0
@ -535,42 +327,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
if activeCall?.remoteVideoTrack != nil { activeCall.remoteStream?.add(renderer)
activeCall?.remoteVideoTrack?.add(renderer)
} else {
cameraRenderers.append(renderer)
}
} }
func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
if activeCall?.remoteVideoTrack != nil { activeCall.remoteStream?.remove(renderer)
activeCall?.remoteVideoTrack?.remove(renderer)
} else {
cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
}
} }
func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) { func startCaptureLocalVideo(_ activeCall: Call) {
if activeCall?.remoteScreenVideoTrack != nil {
activeCall?.remoteScreenVideoTrack?.add(renderer)
} else {
screenRenderers.append(renderer)
}
}
func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
if activeCall?.remoteScreenVideoTrack != nil {
activeCall?.remoteScreenVideoTrack?.remove(renderer)
} else {
screenRenderers.removeAll(where: { $0.isEqual(renderer) })
}
}
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
guard guard
let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer let capturer = activeCall.localCamera as? RTCFileVideoCapturer
else { else {
logger.error("Unable to work with a file capturer") logger.error("Unable to work with a file capturer")
return return
@ -580,10 +348,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
capturer.startCapturing(fromFileNamed: "sounds/video.mp4") capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
#else #else
guard guard
let capturer = capturer as? RTCCameraVideoCapturer, let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device }) let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
else { else {
logger.error("Unable to find a camera or local track") logger.error("Unable to find a camera")
return return
} }
@ -609,6 +377,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif #endif
} }
private func createAudioSender(_ connection: RTCPeerConnection) {
let streamId = "stream"
let audioTrack = createAudioTrack()
connection.add(audioTrack, streamIds: [streamId])
}
private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
let streamId = "stream"
let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
connection.add(localVideoTrack, streamIds: [streamId])
return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
}
private func createAudioTrack() -> RTCAudioTrack { private func createAudioTrack() -> RTCAudioTrack {
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains) let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
@ -616,7 +397,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
return audioTrack return audioTrack
} }
private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) { private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
let localVideoSource = WebRTCClient.factory.videoSource() let localVideoSource = WebRTCClient.factory.videoSource()
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
@ -626,30 +407,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif #endif
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0") let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
startCaptureLocalVideo(device, localCamera) return (localVideoTrack, localCamera, localVideoSource)
return (localCamera, localVideoTrack)
} }
func endCall() { func endCall() {
if #available(iOS 16.0, *) { guard let call = activeCall.wrappedValue else { return }
_endCall()
} else {
// Fixes `connection.close()` getting locked up in iOS15
DispatchQueue.global(qos: .utility).async { self._endCall() }
}
}
private func _endCall() {
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call") logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
call.connection.close() call.connection.close()
call.connection.delegate = nil call.connection.delegate = nil
call.frameEncryptor?.delegate = nil call.frameEncryptor?.delegate = nil
call.frameDecryptor?.delegate = nil call.frameDecryptor?.delegate = nil
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
audioSessionToDefaults() audioSessionToDefaults()
activeCall = nil
} }
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async { func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
@ -658,7 +428,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000) _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs t += stepMs
await action() await action()
} while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@ -719,40 +489,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
logger.debug("Connection should negotiate") logger.debug("Connection should negotiate")
} }
func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
if let track = transceiver.receiver.track {
DispatchQueue.main.async {
// Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
// if let decryptor = self.activeCall?.frameDecryptor {
// transceiver.receiver.setRtcFrameDecryptor(decryptor)
// }
let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
switch source {
case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
case .camera:
self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
self.cameraRenderers.forEach({ renderer in
self.activeCall?.remoteVideoTrack?.add(renderer)
})
self.cameraRenderers.removeAll()
case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
case .screenVideo:
self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
self.screenRenderers.forEach({ renderer in
self.activeCall?.remoteScreenVideoTrack?.add(renderer)
})
self.screenRenderers.removeAll()
case .unknown: ()
}
}
self.setupMuteUnmuteListener(transceiver, track)
}
}
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
guard let connectionStateString = newState.toString(), guard let call = activeCall.wrappedValue,
let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(), let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(), let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString() let signalingStateString = connection.signalingState.toString()
@ -773,14 +514,18 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
switch newState { switch newState {
case .checking: case .checking:
if let frameDecryptor = activeCall?.frameDecryptor { if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) } connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
} }
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true let enableSpeaker: Bool
switch call.localMedia {
case .video: enableSpeaker = true
default: enableSpeaker = false
}
setSpeakerEnabledAndConfigureSession(enableSpeaker) setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection) case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall() case .disconnected, .failed: endCall()
default: () default: do {}
} }
} }
} }
@ -792,7 +537,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)") // logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task { Task {
await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil)) await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
} }
} }
@ -847,42 +592,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
} }
extension WebRTCClient { extension WebRTCClient {
static func isAuthorized(for type: AVMediaType) async -> Bool { func setAudioEnabled(_ enabled: Bool) {
let status = AVCaptureDevice.authorizationStatus(for: type) setTrackEnabled(RTCAudioTrack.self, enabled)
var isAuthorized = status == .authorized
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: type)
}
return isAuthorized
} }
static func showUnauthorizedAlert(for type: AVMediaType) { func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
if type == .audio {
AlertManager.shared.showAlert(Alert(
title: Text("No permission to record speech"),
message: Text("To record speech please grant permission to use Microphone."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
))
} else if type == .video {
AlertManager.shared.showAlert(Alert(
title: Text("No permission to record video"),
message: Text("To record video please grant permission to use Camera."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
))
}
}
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)") logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
audioQueue.async { [weak self] in audioQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@ -895,7 +609,7 @@ extension WebRTCClient {
if enabled { if enabled {
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue) try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() { if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
try self.rtcAudioSession.setPreferredInput(preferred) try self.rtcAudioSession.setPreferredInput(preferred)
} else { } else {
try self.rtcAudioSession.overrideOutputAudioPort(.speaker) try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
@ -905,7 +619,7 @@ extension WebRTCClient {
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.overrideOutputAudioPort(.none) try self.rtcAudioSession.overrideOutputAudioPort(.none)
} }
if hasExternalAudioDevice && !skipExternalDevice { if hasExternalAudioDevice {
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker") logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
} }
try self.rtcAudioSession.setActive(true) try self.rtcAudioSession.setActive(true)
@ -936,59 +650,25 @@ extension WebRTCClient {
} }
} }
@MainActor func setVideoEnabled(_ enabled: Bool) {
func setAudioEnabled(_ enabled: Bool) { setTrackEnabled(RTCVideoTrack.self, enabled)
if activeCall != nil {
activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
} else if notConnectedCall != nil {
notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
}
ChatModel.shared.activeCall?.localMediaSources.mic = enabled
}
@MainActor
func setCameraEnabled(_ enabled: Bool) {
if let call = activeCall {
if enabled {
if call.localVideoTrack == nil {
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
let (camera, track) = createVideoTrackAndStartCapture(device)
activeCall?.localCamera = camera
activeCall?.localVideoTrack = track
}
} else {
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
activeCall?.localCamera = nil
activeCall?.localVideoTrack = nil
}
call.connection.transceivers
.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
.sender.track = activeCall?.localVideoTrack
ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
} else if let call = notConnectedCall {
if enabled {
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
} else {
(call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
notConnectedCall?.localCameraAndTrack = nil
}
ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
}
} }
func flipCamera() { func flipCamera() {
let device = activeCall?.device ?? notConnectedCall?.device switch activeCall.wrappedValue?.device {
if activeCall != nil { case .front: activeCall.wrappedValue?.device = .back
activeCall?.device = device == .front ? .back : .front case .back: activeCall.wrappedValue?.device = .front
} else { default: ()
notConnectedCall?.device = device == .front ? .back : .front
} }
startCaptureLocalVideo( if let call = activeCall.wrappedValue {
activeCall?.device ?? notConnectedCall?.device, startCaptureLocalVideo(call)
(activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer }
) }
private func setTrackEnabled<T: RTCMediaStreamTrack>(_ type: T.Type, _ enabled: Bool) {
activeCall.wrappedValue?.connection.transceivers
.compactMap { $0.sender.track as? T }
.forEach { $0.isEnabled = enabled }
} }
} }

View file

@ -45,7 +45,7 @@ struct ChatInfoToolbar: View {
} }
private var contactVerifiedShield: Text { private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.baselineOffset(1) .baselineOffset(1)

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView { class AnimatedImageView: UIView {
var image: UIImage? = nil var image: UIImage? = nil
var imageView: UIImageView? = nil var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented") fatalError("Not implemented")
} }
convenience init(image: UIImage, contentMode: UIView.ContentMode) { convenience init(image: UIImage) {
self.init() self.init()
self.image = image self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
} }
@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView { if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData { if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
subview.removeFromSuperview() subview.removeFromSuperview()
} }
@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable { struct SwiftyGif: UIViewRepresentable {
private let image: UIImage private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) { init(image: UIImage) {
self.image = image self.image = image
self.contentMode = contentMode
} }
func makeUIView(context: Context) -> AnimatedImageView { func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode) AnimatedImageView(image: image)
} }
func updateUIView(_ imageView: AnimatedImageView, context: Context) { func updateUIView(_ imageView: AnimatedImageView, context: Context) {

View file

@ -50,7 +50,7 @@ struct CICallItemView: View {
Image(systemName: "phone.connection").foregroundColor(.green) Image(systemName: "phone.connection").foregroundColor(.green)
} }
private func endedCallIcon(_ sent: Bool) -> some View { @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack { HStack {
Image(systemName: "phone.down") Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary) Text(durationText(duration)).foregroundColor(theme.colors.secondary)
@ -60,16 +60,16 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View { @ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo { if case let .direct(contact) = chat.chatInfo {
Label("Answer call", systemImage: "phone.arrow.down.left") Button {
.foregroundColor(theme.colors.primary) if let invitation = m.callInvitations[contact.id] {
.simultaneousGesture(TapGesture().onEnded { CallController.shared.answerCall(invitation: invitation)
if let invitation = m.callInvitations[contact.id] { logger.debug("acceptCallButton call answered")
CallController.shared.answerCall(invitation: invitation) } else {
logger.debug("acceptCallButton call answered") AlertManager.shared.showAlertMsg(title: "Call already ended!")
} else { }
AlertManager.shared.showAlertMsg(title: "Call already ended!") } label: {
} Label("Answer call", systemImage: "phone.arrow.down.left")
}) }
} else { } else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
} }

View file

@ -11,11 +11,10 @@ import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature var feature: Feature
var icon: String? = nil var icon: String? = nil
var iconColor: Color var iconColor: Color
@ -54,8 +53,8 @@ struct CIChatFeatureView: View {
var fs: [FeatureInfo] = [] var fs: [FeatureInfo] = []
var icons: Set<String> = [] var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) { if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count, while i < m.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) { let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) { if !icons.contains(f.icon) {
fs.insert(f, at: 0) fs.insert(f, at: 0)
icons.insert(f.icon) icons.insert(f.icon)
@ -106,9 +105,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider { struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false) let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView( CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
chat: Chat.sampleData,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
} }
} }

View file

@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View {
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept") featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil) allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
}) }
} else { } else {
featurePreferenceView() featurePreferenceView()
} }
@ -47,7 +47,7 @@ struct CIFeaturePreferenceView: View {
+ Text(acceptText) + Text(acceptText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
+ Text(verbatim: " ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)

View file

@ -14,16 +14,12 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
var smallViewSize: CGFloat?
var body: some View { var body: some View {
if smallViewSize != nil { let metaReserve = edited
fileIndicator() ? " "
.simultaneousGesture(TapGesture().onEnded(fileAction)) : " "
} else { Button(action: fileAction) {
let metaReserve = edited
? " "
: " "
HStack(alignment: .bottom, spacing: 6) { HStack(alignment: .bottom, spacing: 6) {
fileIndicator() fileIndicator()
.padding(.top, 5) .padding(.top, 5)
@ -49,12 +45,10 @@ struct CIFileView: View {
.padding(.bottom, 6) .padding(.bottom, 6)
.padding(.leading, 10) .padding(.leading, 10)
.padding(.trailing, 12) .padding(.trailing, 12)
.simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive)
} }
.disabled(!itemInteractive)
} }
@inline(__always)
private var itemInteractive: Bool { private var itemInteractive: Bool {
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {
@ -118,10 +112,16 @@ struct CIFileView: View {
} }
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError") logger.debug("CIFileView fileAction - in .rcvError")
showFileErrorAlert(rcvFileError) AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning") logger.debug("CIFileView fileAction - in .rcvWarning")
showFileErrorAlert(rcvFileError, temporary: true) AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case .sndStored: case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored") logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
@ -134,10 +134,16 @@ struct CIFileView: View {
} }
case let .sndError(sndFileError): case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError") logger.debug("CIFileView fileAction - in .sndError")
showFileErrorAlert(sndFileError) AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning") logger.debug("CIFileView fileAction - in .sndWarning")
showFileErrorAlert(sndFileError, temporary: true) AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: break default: break
} }
} }
@ -189,22 +195,21 @@ struct CIFileView: View {
} }
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30 ZStack(alignment: .center) {
return ZStack(alignment: .center) {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: 30, height: 30)
.foregroundColor(color) .foregroundColor(color)
if let innerIcon = innerIcon, if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) { let innerIconSize = innerIconSize {
Image(systemName: innerIcon) Image(systemName: innerIcon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(maxHeight: 16) .frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize) .frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.top, size / 2.5) .padding(.top, 12)
} }
} }
} }
@ -256,26 +261,6 @@ func saveCryptoFile(_ fileSource: CryptoFile) {
} }
} }
func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
let title: String = if temporary {
NSLocalizedString("Temporary file error", comment: "file error alert title")
} else {
NSLocalizedString("File error", comment: "file error alert title")
}
if let btn = err.moreInfoButton {
showAlert(title, message: err.errorInfo) {
[
okAlertAction,
UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
UIApplication.shared.open(contentModerationPostLink)
})
]
}
} else {
showAlert(title, message: err.errorInfo)
}
}
struct CIFileView_Previews: PreviewProvider { struct CIFileView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let sentFile: ChatItem = ChatItem( let sentFile: ChatItem = ChatItem(
@ -293,18 +278,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil file: nil
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }

View file

@ -12,7 +12,6 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
@ -45,16 +44,16 @@ struct CIGroupInvitationView: View {
Text(chatIncognito ? "Tap to join incognito" : "Tap to join") Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout) .font(.callout)
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
} else { } else {
( (
groupInvitationText() groupInvitationText()
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@ -70,7 +69,7 @@ struct CIGroupInvitationView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in .onChange(of: inProgress) { inProgress in
@ -84,12 +83,12 @@ struct CIGroupInvitationView: View {
} }
if action { if action {
v.simultaneousGesture(TapGesture().onEnded { v.onTapGesture {
inProgress = true inProgress = true
joinGroup(groupInvitation.groupId) { joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false } await MainActor.run { inProgress = false }
} }
}) }
.disabled(inProgress) .disabled(inProgress)
} else { } else {
v v

View file

@ -12,40 +12,26 @@ import SimpleXChat
struct CIImageView: View { struct CIImageView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
let chatItem: ChatItem let chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage? var preview: UIImage?
let maxWidth: CGFloat let maxWidth: CGFloat
var imgWidth: CGFloat? var imgWidth: CGFloat?
var smallView: Bool = false @State private var showFullScreenImage = false
@Binding var showFullScreenImage: Bool
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) { if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) { .fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
} }
.onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false showFullScreenImage = false
} }
} else if let preview { } else if let preview {
Group { imageView(preview)
if smallView { .onTapGesture {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.simultaneousGesture(TapGesture().onEnded {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation, .rcvAborted:
@ -72,22 +58,31 @@ struct CIImageView: View {
case .rcvComplete: () // ? case .rcvComplete: () // ?
case .rcvCancelled: () // TODO case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
showFileErrorAlert(rcvFileError) AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
showFileErrorAlert(rcvFileError, temporary: true) AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case let .sndError(sndFileError): case let .sndError(sndFileError):
showFileErrorAlert(sndFileError) AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
showFileErrorAlert(sndFileError, temporary: true) AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: () default: ()
} }
} }
}) }
} }
} }
.onDisappear {
showFullScreenImage = false
}
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
@ -103,26 +98,7 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width) .frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit() .scaledToFit()
} }
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator()
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
} }
} }
@ -156,9 +132,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white)
.padding(padding) .padding(padding)
} }
@ -169,12 +145,4 @@ struct CIImageView: View {
.tint(.white) .tint(.white)
.padding(8) .padding(8)
} }
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
} }

View file

@ -7,11 +7,10 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
struct CIInvalidJSONView: View { struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var json: Data? var json: String
@State private var showJSON = false @State private var showJSON = false
var body: some View { var body: some View {
@ -24,16 +23,16 @@ struct CIInvalidJSONView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
.simultaneousGesture(TapGesture().onEnded { showJSON = true }) .onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) { .appSheet(isPresented: $showJSON) {
invalidJSONView(dataToString(json)) invalidJSONView(json)
} }
} }
} }
func invalidJSONView(_ json: String) -> some View { func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Button { // this is used in the sheet, Button works here Button {
showShareSheet(items: [json]) showShareSheet(items: [json])
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
@ -50,6 +49,6 @@ func invalidJSONView(_ json: String) -> some View {
struct CIInvalidJSONView_Previews: PreviewProvider { struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
CIInvalidJSONView(json: "{}".data(using: .utf8)!) CIInvalidJSONView(json: "{}")
} }
} }

View file

@ -12,24 +12,22 @@ import SimpleXChat
struct CILinkView: View { struct CILinkView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = imageFromBase64(linkPreview.image) { if let uiImage = UIImage(base64Encoded: linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
}
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title) Text(linkPreview.title)
.lineLimit(3) .lineLimit(3)
// if linkPreview.description != "" {
// Text(linkPreview.description)
// .font(.subheadline)
// .lineLimit(12)
// }
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
@ -37,32 +35,10 @@ struct CILinkView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
} }
} }
} }
func openBrowserAlert(uri: URL) {
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uri.absoluteString,
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default,
handler: { _ in }
),
UIAlertAction(
title: NSLocalizedString("Open", comment: "alert action"),
style: .default,
handler: { _ in UIApplication.shared.open(uri) }
)
]}
)
}
struct LargeLinkPreview_Previews: PreviewProvider { struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let preview = LinkPreview( let preview = LinkPreview(

View file

@ -20,11 +20,12 @@ struct CIMemberCreatedContactView: View {
case let .groupRcv(groupMember): case let .groupRcv(groupMember):
if let contactId = groupMember.memberContactId { if let contactId = groupMember.memberContactId {
memberCreatedContactView(openText: "Open") memberCreatedContactView(openText: "Open")
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
ItemsModel.shared.loadOpenChat("@\(contactId)") { dismissAllSheets(animated: true)
dismissAllSheets(animated: true) DispatchQueue.main.async {
m.chatId = "@\(contactId)"
} }
}) }
} else { } else {
memberCreatedContactView() memberCreatedContactView()
} }
@ -44,7 +45,7 @@ struct CIMemberCreatedContactView: View {
+ Text(openText) + Text(openText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
+ Text(verbatim: " ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)

View file

@ -12,13 +12,11 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor: Color
var paleMetaColor = Color(uiColor: .tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true var showStatus = true
var showEdited = true var showEdited = true
var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@ -26,145 +24,93 @@ struct CIMetaView: View {
if chatItem.isDeletedContent { if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor) chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else { } else {
ZStack { let meta = chatItem.meta
ciMetaText( let ttl = chat.chatInfo.timedMessagesTTL
chatItem.meta, let encrypted = chatItem.encryptedFile
chatTTL: chat.chatInfo.timedMessagesTTL, switch meta.itemStatus {
encrypted: chatItem.encryptedFile, case let .sndSent(sndProgress):
color: metaColor, switch sndProgress {
paleColor: paleMetaColor, case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
colorMode: invertedMaterial case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
? .invertedMaterial
: .normal,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
).invertedForegroundStyle(enabled: invertedMaterial)
if invertedMaterial {
ciMetaText(
chatItem.meta,
chatTTL: chat.chatInfo.timedMessagesTTL,
encrypted: chatItem.encryptedFile,
colorMode: .normal,
onlyOverrides: true,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
)
} }
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case .partial:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
} }
} }
} }
enum MetaColorMode { enum SentCheckmark {
// Renders provided colours case sent
case normal case rcvd1
// Fully transparent meta - used for reserving space case rcvd2
case transparent
// Renders white on dark backgrounds and black on light ones
case invertedMaterial
func resolve(_ c: Color?) -> Color? {
switch self {
case .normal: c
case .transparent: .clear
case .invertedMaterial: nil
}
}
func statusSpacer(_ sent: Bool) -> Text {
switch self {
case .normal, .transparent:
Text(
sent
? Image("checkmark.wide")
: Image(systemName: "circlebadge.fill")
).foregroundColor(.clear)
case .invertedMaterial: textSpace.kerning(13)
}
}
} }
func ciMetaText( func ciMetaText(
_ meta: CIMeta, _ meta: CIMeta,
chatTTL: Int?, chatTTL: Int?,
encrypted: Bool?, encrypted: Bool?,
color: Color = .clear, // we use this function to reserve space without rendering meta color: Color = .clear,
paleColor: Color? = nil,
primaryColor: Color = .accentColor, primaryColor: Color = .accentColor,
colorMode: MetaColorMode = .normal, transparent: Bool = false,
onlyOverrides: Bool = false, // only render colors that differ from base sent: SentCheckmark? = nil,
showStatus: Bool = true, showStatus: Bool = true,
showEdited: Bool = true, showEdited: Bool = true,
showViaProxy: Bool, showViaProxy: Bool
showTimesamp: Bool
) -> Text { ) -> Text {
var r = Text("") var r = Text("")
var space: Text? = nil
let appendSpace = {
if let sp = space {
r = r + sp
space = nil
}
}
let resolved = colorMode.resolve(color)
if showEdited, meta.itemEdited { if showEdited, meta.itemEdited {
r = r + statusIconText("pencil", resolved) r = r + statusIconText("pencil", color)
} }
if meta.disappearing { if meta.disappearing {
r = r + statusIconText("timer", resolved).font(.caption2) r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl let ttl = meta.itemTimed?.ttl
if ttl != chatTTL { if ttl != chatTTL {
r = r + colored(Text(shortTimeText(ttl)), resolved) r = r + Text(shortTimeText(ttl)).foregroundColor(color)
} }
space = textSpace r = r + Text(" ")
} }
if showViaProxy, meta.sentViaProxy == true { if showViaProxy, meta.sentViaProxy == true {
appendSpace() r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
} }
if showStatus { if showStatus {
appendSpace() if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) { let t = Text(Image(systemName: icon)).font(.caption2)
let metaColor = if onlyOverrides && statusColor == color { let gap = Text(" ").kerning(-1.25)
Color.clear let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
} else { switch sent {
colorMode.resolve(statusColor) case nil: r = r + t1
case .sent: r = r + t1 + gap
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
} }
r = r + colored(Text(image), metaColor) r = r + Text(" ")
} else if !meta.disappearing { } else if !meta.disappearing {
r = r + colorMode.statusSpacer(meta.itemStatus.sent) r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
space = textSpace
} }
if let enc = encrypted { if let enc = encrypted {
appendSpace() r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = textSpace
}
if showTimesamp {
appendSpace()
r = r + colored(meta.timestampText, resolved)
} }
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption) return r.font(.caption)
} }
@inline(__always) private func statusIconText(_ icon: String, _ color: Color) -> Text {
private func statusIconText(_ icon: String, _ color: Color?) -> Text { Text(Image(systemName: icon)).foregroundColor(color)
colored(Text(Image(systemName: icon)), color)
}
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
@inline(__always)
private func colored(_ t: Text, _ color: Color?) -> Text {
if let color {
t.foregroundColor(color)
} else {
t
}
} }
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {

View file

@ -15,7 +15,6 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@ -28,7 +27,7 @@ struct CIRcvDecryptionError: View {
case syncNotSupportedContactAlert case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert case syncNotSupportedMemberAlert
case decryptionErrorAlert case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@ -48,7 +47,7 @@ struct CIRcvDecryptionError: View {
if case let .group(groupInfo) = chat.chatInfo, if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir { case let .groupRcv(groupMember) = chatItem.chatDir {
do { do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats { if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s) m.updateGroupMemberConnectionStats(groupInfo, member, s)
} }
@ -63,46 +62,43 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message()) case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message()) case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message()) case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
} }
private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
Group { if case let .direct(contact) = chat.chatInfo,
if case let .direct(contact) = chat.chatInfo, let contactStats = contact.activeConn?.connectionStats {
let contactStats = contact.activeConn?.connectionStats { if contactStats.ratchetSyncAllowed {
if contactStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) {
decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncContactConnection(contact) }
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo, } else if !contactStats.ratchetSyncSupported {
case let .groupRcv(groupMember) = chatItem.chatDir, decryptionErrorItemFixButton(syncSupported: false) {
let mem = m.getGroupMember(groupMember.groupMemberId), alert = .syncNotSupportedContactAlert
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} }
} else { } else {
basicDecryptionErrorItem() basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
} }
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
} }
private func basicDecryptionErrorItem() -> some View { private func basicDecryptionErrorItem() -> some View {
@ -121,20 +117,21 @@ struct CIRcvDecryptionError: View {
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")) Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout) .font(.callout)
+ textSpace + Text(" ")
+ Text("Fix connection") + Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout) .font(.callout)
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.simultaneousGesture(TapGesture().onEnded(onClick)) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -144,15 +141,16 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.simultaneousGesture(TapGesture().onEnded(onClick)) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -161,13 +159,13 @@ struct CIRcvDecryptionError: View {
let why = Text(decryptErrorReason) let why = Text(decryptErrorReason)
switch msgDecryptError { switch msgDecryptError {
case .ratchetHeader: case .ratchetHeader:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .tooManySkipped: case .tooManySkipped:
message = Text("\(msgCount) messages skipped.") + textNewLine + why message = Text("\(msgCount) messages skipped.") + Text("\n") + why
case .ratchetEarlier: case .ratchetEarlier:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .other: case .other:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .ratchetSync: case .ratchetSync:
message = Text("Encryption re-negotiation failed.") message = Text("Encryption re-negotiation failed.")
} }

View file

@ -20,125 +20,83 @@ struct CIVideoView: View {
@State private var videoPlaying: Bool = false @State private var videoPlaying: Bool = false
private let maxWidth: CGFloat private let maxWidth: CGFloat
private var videoWidth: CGFloat? private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer? @State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var urlDecrypted: URL? @State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false @State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil @State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) { init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
self.chatItem = chatItem self.chatItem = chatItem
self.preview = preview self.preview = preview
self._duration = State(initialValue: duration) self._duration = State(initialValue: duration)
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.videoWidth = videoWidth self.videoWidth = videoWidth
self.smallView = smallView if let url = getLoadedVideo(chatItem.file) {
self._showFullScreenPlayer = showFullscreenPlayer let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
} }
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file, let preview { if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
if let urlDecrypted { videoView(player, decrypted, file, preview, duration)
if smallView { } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
smallVideoView(urlDecrypted, file, preview) videoViewEncrypted(file, defaultPreview, duration)
} else if let player { } else if let preview {
videoView(player, urlDecrypted, file, preview, duration) imageView(preview)
} .onTapGesture {
} else if file.loaded { if let file = file {
if smallView { switch file.fileStatus {
smallVideoViewEncrypted(file, preview) case .rcvInvitation, .rcvAborted:
} else { receiveFileIfValidSize(file: file, receiveFile: receiveFile)
videoViewEncrypted(file, preview, duration) case .rcvAccepted:
} switch file.fileProtocol {
} else { case .xftp:
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } AlertManager.shared.showAlertMsg(
.simultaneousGesture(TapGesture().onEnded { title: "Waiting for video",
switch file.fileStatus { message: "Video will be received when your contact completes uploading it."
case .rcvInvitation, .rcvAborted: )
receiveFileIfValidSize(file: file, receiveFile: receiveFile) case .smp:
case .rcvAccepted: AlertManager.shared.showAlertMsg(
switch file.fileProtocol { title: "Waiting for video",
case .xftp: message: "Video will be received when your contact is online, please wait or check later!"
AlertManager.shared.showAlertMsg( )
title: "Waiting for video", case .local: ()
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: ()
} }
}) case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
default: ()
}
}
} }
} }
if !smallView { durationProgress()
durationProgress()
}
} }
if !blurred, let file, showDownloadButton(file.fileStatus) { if let file = file, showDownloadButton(file.fileStatus) {
if !smallView || !file.showStatusIconInSmallView { Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
.simultaneousGesture(TapGesture().onEnded {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
})
} }
} }
} }
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
} }
private func setupPlayer(_ file: CIFile?) { private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus { switch fileStatus {
case .rcvInvitation: true case .rcvInvitation: true
case .rcvAborted: true case .rcvAborted: true
@ -151,29 +109,33 @@ struct CIVideoView: View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview) imageView(defaultPreview)
.simultaneousGesture(TapGesture().onEnded { .fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture {
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
} }
}) }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !blurred { if !decryptionInProgress {
if !decryptionInProgress { Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash") playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
})
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} }
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} }
} }
} }
@ -192,31 +154,32 @@ struct CIVideoView: View {
videoPlaying = false videoPlaying = false
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .fullScreenCover(isPresented: $showFullScreenPlayer) {
.if(!blurred) { v in fullScreenPlayer(url)
v.simultaneousGesture(TapGesture().onEnded { }
switch player.timeControlStatus { .onTapGesture {
case .playing: switch player.timeControlStatus {
player.pause() case .playing:
videoPlaying = false player.pause()
case .paused: videoPlaying = false
if canBePlayed { case .paused:
showFullScreenPlayer = true if canBePlayed {
} showFullScreenPlayer = true
default: ()
} }
}) default: ()
}
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !videoPlaying && !blurred { if !videoPlaying {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash") Button {
.simultaneousGesture(TapGesture().onEnded { m.stopPreviousRecPlay = url
m.stopPreviousRecPlay = url player.play()
player.play() } label: {
}) playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.disabled(!canBePlayed) }
.disabled(!canBePlayed)
} }
} }
fileStatusIcon() fileStatusIcon()
@ -231,53 +194,14 @@ struct CIVideoView: View {
} }
} }
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12) .frame(width: 12, height: 12)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, smallView ? 0 : 4) .padding(.leading, 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
@ -285,27 +209,35 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View { private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12) .frame(width: 12, height: 12)
.tint(color) .tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
private var fileSizeString: String {
if let file = chatItem.file, !videoPlaying {
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
} else {
""
}
}
private func durationProgress() -> some View { private func durationProgress() -> some View {
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString) HStack {
.invertedForegroundStyle() Text("\(durationText(videoPlaying ? progress : duration))")
.foregroundColor(.white)
.font(.caption) .font(.caption)
.padding(.vertical, 6) .padding(.vertical, 3)
.padding(.horizontal, 12) .padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding([.top, .leading], 6)
if let file = chatItem.file, !videoPlaying {
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding(.top, 6)
}
}
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
@ -315,23 +247,7 @@ struct CIVideoView: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: w) .frame(width: w)
.modifier(PrivacyBlur(blurred: $blurred)) fileStatusIcon()
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
} }
} }
@ -354,14 +270,20 @@ struct CIVideoView: View {
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError): case let .sndError(sndFileError):
fileIcon("xmark", 10, 13) fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(sndFileError) AlertManager.shared.showAlert(Alert(
}) title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13) fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(sndFileError, temporary: true) AlertManager.shared.showAlert(Alert(
}) title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal): case let .rcvTransfer(rcvProgress, rcvTotal):
@ -375,14 +297,20 @@ struct CIVideoView: View {
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13) fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(rcvFileError) AlertManager.shared.showAlert(Alert(
}) title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13) fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(rcvFileError, temporary: true) AlertManager.shared.showAlert(Alert(
}) title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
} }
} }
@ -391,10 +319,10 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.padding(smallView ? 0 : padding) .foregroundColor(.white)
.padding(padding)
} }
private func progressView() -> some View { private func progressView() -> some View {
@ -402,17 +330,19 @@ struct CIVideoView: View {
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.tint(.white) .tint(.white)
.padding(smallView ? 0 : 11) .padding(11)
} }
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle() Circle()
.trim(from: 0, to: Double(progress) / Double(total)) .trim(from: 0, to: Double(progress) / Double(total))
.stroke(style: StrokeStyle(lineWidth: 2)) .stroke(
.invertedForegroundStyle() Color(uiColor: .white),
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], 11)
} }
// TODO encrypt: where file size is checked? // TODO encrypt: where file size is checked?
@ -429,7 +359,7 @@ struct CIVideoView: View {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer) VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: { .overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here Button(action: { showFullScreenPlayer = false },
label: { label: {
Image(systemName: "multiply") Image(systemName: "multiply")
.resizable() .resizable()
@ -452,8 +382,7 @@ struct CIVideoView: View {
) )
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) { DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+ m.stopPreviousRecPlay = url
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer { if let player = fullPlayer {
player.play() player.play()
var played = false var played = false
@ -490,12 +419,10 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run { await MainActor.run {
if let decrypted = urlDecrypted { if let decrypted = urlDecrypted {
if !smallView { player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted) fullPlayer = AVPlayer(url: decrypted)
} }
decryptionInProgress = false decryptionInProgress = true
completed?() completed?()
} }
} }

View file

@ -15,26 +15,15 @@ struct CIVoiceView: View {
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil @Binding var audioPlayer: AudioPlayer?
@State var playbackState: VoiceMessagePlaybackState = .noPlayback @Binding var playbackState: VoiceMessagePlaybackState
@State var playbackTime: TimeInterval? = nil @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
Group { Group {
if smallViewSize != nil { if chatItem.chatDir.sent {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) { VStack (alignment: .trailing, spacing: 6) {
HStack { HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@ -65,13 +54,7 @@ struct CIVoiceView: View {
} }
private func player() -> some View { private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize { VoiceMessagePlayer(
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@ -80,8 +63,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: sizeMultiplier
) )
} }
@ -137,7 +119,6 @@ struct VoiceMessagePlayerTime: View {
} }
struct VoiceMessagePlayer: View { struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
@ -149,9 +130,7 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View { var body: some View {
ZStack { ZStack {
@ -168,14 +147,20 @@ struct VoiceMessagePlayer: View {
case .sndCancelled: playbackButton() case .sndCancelled: playbackButton()
case let .sndError(sndFileError): case let .sndError(sndFileError):
fileStatusIcon("multiply", 14) fileStatusIcon("multiply", 14)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(sndFileError) AlertManager.shared.showAlert(Alert(
}) title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16) fileStatusIcon("exclamationmark.triangle.fill", 16)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(sndFileError, temporary: true) AlertManager.shared.showAlert(Alert(
}) title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon() case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon() case .rcvTransfer: loadingIcon()
@ -184,14 +169,20 @@ struct VoiceMessagePlayer: View {
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14) fileStatusIcon("multiply", 14)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(rcvFileError) AlertManager.shared.showAlert(Alert(
}) title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16) fileStatusIcon("exclamationmark.triangle.fill", 16)
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
showFileErrorAlert(rcvFileError, temporary: true) AlertManager.shared.showAlert(Alert(
}) title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
} }
} else { } else {
@ -199,85 +190,51 @@ struct VoiceMessagePlayer: View {
} }
} }
.onAppear { .onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) } seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) { audioPlayer?.onTimer = { playbackTime = $0 }
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onFinishPlayback = { audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
} }
} }
.onChange(of: chatModel.stopPreviousRecPlay) { it in .onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback() audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} }
} }
.onChange(of: playbackState) { state in .onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
} }
} }
private func playbackButton() -> some View { @ViewBuilder private func playbackButton() -> some View {
let icon = switch playbackState { switch playbackState {
case .noPlayback: "play.fill" case .noPlayback:
case .playing: "pause.fill" Button {
case .paused: "play.fill" if let recordingSource = getLoadedFileSource(recordingFile) {
} startPlayback(recordingSource)
return playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded { _ in
switch playbackState {
case .noPlayback:
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
case .playing:
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
case .paused:
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} }
}) } label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
}
} }
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
@ -285,35 +242,28 @@ struct VoiceMessagePlayer: View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier) .frame(width: 20, height: 20)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0) .padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
if recordingTime > 0 { if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime) ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter .frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
} }
} }
} }
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary) Button {
.simultaneousGesture(TapGesture().onEnded { Task {
Task { if let user = chatModel.currentUser {
if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId)
await receiveFile(user: user, fileId: recordingFile.fileId)
}
} }
}) }
} } label: {
playPauseIcon(icon, theme.colors.primary)
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} }
} }
@ -338,99 +288,33 @@ struct VoiceMessagePlayer: View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier) .frame(width: size, height: size)
.foregroundColor(Color(uiColor: .tertiaryLabel)) .foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
} }
private func loadingIcon() -> some View { private func loadingIcon() -> some View {
ProgressView() ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier) .frame(width: 30, height: 30)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
} }
private func startPlayback(_ recordingSource: CryptoFile) { private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath) chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer( audioPlayer = AudioPlayer(
onTimer: { onTimer: { playbackTime = $0 },
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onFinishPlayback: { onFinishPlayback: {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
} }
) )
audioPlayer?.start(fileSource: recordingSource, at: playbackTime) audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing playbackState = .playing
notifyStateChange()
} }
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
@inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
@inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
} }
struct CIVoiceView_Previews: PreviewProvider { struct CIVoiceView_Previews: PreviewProvider {
@ -455,12 +339,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }

View file

@ -13,23 +13,21 @@ import SimpleXChat
struct FramedCIVoiceView: View { struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
HStack { HStack {
VoiceMessagePlayer( VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@ -38,8 +36,7 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: 1
) )
VoiceMessagePlayerTime( VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@ -92,13 +89,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete) file: CIFile.getSample(fileStatus: .sndComplete)
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }

File diff suppressed because one or more lines are too long

View file

@ -13,8 +13,8 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@State var chatItem: ChatItem @State var chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage? @State var image: UIImage?
@State var player: AVPlayer? = nil @State var player: AVPlayer? = nil
@State var url: URL? = nil @State var url: URL? = nil
@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width) let w = abs(t.width)
if t.height > 60 && t.height > w * 2 { if t.height > 60 && t.height > w * 2 {
showView = false showView = false
scrollToItemId?(chatItem.id) scrollModel.scrollToItem(id: chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling { } else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0 let previous = t.width > 0
scrolling = true scrolling = true
@ -126,7 +126,7 @@ struct FullScreenMediaView: View {
.scaledToFit() .scaledToFit()
} }
} }
.onTapGesture { showView = false } // this is used in full screen view, onTapGesture works .onTapGesture { showView = false }
} }
private func videoView( _ player: AVPlayer, _ url: URL) -> some View { private func videoView( _ player: AVPlayer, _ url: URL) -> some View {

View file

@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View {
case .msgBadHash: case .msgBadHash:
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Bad message hash"), title: Text("Bad message hash"),
message: Text("The hash of the previous message is different.") + textNewLine + message: Text("The hash of the previous message is different.") + Text("\n") +
Text(decryptErrorReason) + textNewLine + Text(decryptErrorReason) + Text("\n") +
Text("Please report it to the developers.") Text("Please report it to the developers.")
)) ))
case .msgBadId: msgBadIdAlert() case .msgBadId: msgBadIdAlert()
@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View {
message: Text(""" message: Text("""
The ID of the next message is incorrect (less or equal to the previous). 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. It can happen because of some bug or when the connection is compromised.
""") + textNewLine + """) + Text("\n") +
Text("Please report it to the developers.") Text("Please report it to the developers.")
)) ))
} }
@ -69,9 +69,9 @@ struct CIMsgError: View {
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
.simultaneousGesture(TapGesture().onEnded(onTap)) .onTapGesture(perform: onTap)
} }
} }

View file

@ -12,17 +12,17 @@ import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View { var body: some View {
(Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText) (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -35,8 +35,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0 var blockedByAdmin = 0
var deleted = 0 var deleted = 0
var moderatedBy: Set<String> = [] var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count, while i < m.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]), let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory, ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted { let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted { switch itemDeleted {
@ -67,15 +67,11 @@ struct MarkedDeletedItemView: View {
// same texts are in markedDeletedText in ChatPreviewView, but it returns String; // same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type // can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey { var markedDeletedText: LocalizedStringKey {
if chatItem.meta.itemDeleted != nil, chatItem.isReport { switch chatItem.meta.itemDeleted {
"archived report" case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
} else { case .blocked: "blocked"
switch chatItem.meta.itemDeleted { case .blockedByAdmin: "blocked by admin"
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" case .deleted, nil: "marked deleted"
case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin"
case .deleted, nil: "marked deleted"
}
} }
} }
} }
@ -83,10 +79,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider { struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
MarkedDeletedItemView( MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
chat: Chat.sampleData,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }

View file

@ -11,74 +11,49 @@ import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString { private let noTyping = Text(" ")
let res = NSMutableAttributedString()
for w in ws { private let typingIndicators: [Text] = [
res.append(NSAttributedString(string: ".", attributes: [ (typing(.black) + typing() + typing()),
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w), (typing(.bold) + typing(.black) + typing()),
.kern: -2 as NSNumber, (typing() + typing(.bold) + typing(.black)),
.foregroundColor: UIColor(theme.colors.secondary) (typing() + typing() + typing(.bold))
])) ]
}
return res private func typing(_ w: Font.Weight = .light) -> Text {
Text(".").fontWeight(w)
} }
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.containerBackground) var containerBackground: UIColor
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var textStyle: UIFont.TextStyle
var sender: String? = nil var sender: String? = nil
var meta: CIMeta? = nil var meta: CIMeta? = nil
var mentions: [String: CIMention]? = nil
var userMemberId: String? = nil
var rightToLeft = false var rightToLeft = false
var prefix: NSAttributedString? = nil var showSecrets: Bool
@State private var showSecrets: Set<Int> = []
@State private var typingIdx = 0 @State private var typingIdx = 0
@State private var timer: Timer? @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 @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
let v = msgContentView()
if meta?.isLive == true { if meta?.isLive == true {
v.onAppear { msgContentView()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) .onAppear { switchTyping() }
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) .onDisappear(perform: stopTyping)
.onChange(of: meta?.isLive, perform: switchTyping) .onChange(of: meta?.isLive, perform: switchTyping)
.onChange(of: meta?.recent, perform: switchTyping) .onChange(of: meta?.recent, perform: switchTyping)
} else { } else {
v msgContentView()
} }
} }
private func switchTyping(_: Bool? = nil) { private func switchTyping(_: Bool? = nil) {
if let meta = meta, meta.isLive && meta.recent { 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 timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
typingIdx = typingIdx + 1 typingIdx = (typingIdx + 1) % typingIndicators.count
} }
} else { } else {
stopTyping() stopTyping()
@ -88,276 +63,95 @@ struct MsgContentView: View {
private func stopTyping() { private func stopTyping() {
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
typingIdx = 0
} }
@inline(__always) private func msgContentView() -> Text {
private func msgContentView() -> some View { var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix)
let s = r.string
let t: Text
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
s.append(typingIndicator(mt.recent)) v = v + typingIndicator(mt.recent)
} }
t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) v = v + reserveSpaceForMeta(mt)
} else {
t = Text(AttributedString(s))
} }
return msgTextResultView(r, t, showSecrets: $showSecrets) return v
} }
@inline(__always) private func typingIndicator(_ recent: Bool) -> Text {
private func typingIndicator(_ recent: Bool) -> NSAttributedString { return (recent ? typingIndicators[typingIdx] : noTyping)
recent && !typingIndicators.isEmpty .font(.body.monospaced())
? typingIndicators[typingIdx % 4] .kerning(-2)
: noTyping .foregroundColor(theme.colors.secondary)
} }
@inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
} }
} }
func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = nil) -> some View { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
t.if(r.hasSecrets, transform: hiddenSecretsView) let s = text
.if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) } var res: Text
} if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets)
@inline(__always) var i = 1
private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding<Set<Int>>? = nil) -> some View { while i < ft.count {
return GeometryReader { g in res = res + formatText(ft[i], preview, showSecret: showSecrets)
Rectangle() i = i + 1
.fill(Color.clear)
.contentShape(Rectangle())
.simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in
let t = event.translation
if t.width * t.width + t.height * t.height > 100 { return }
let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil)
let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit
var index: CFIndex?
if let lines = CTFrameGetLines(frame) as? [CTLine] {
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for i in 0 ..< lines.count {
let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) {
index = CTLineGetStringIndexForPosition(lines[i], point)
break
}
}
}
if let index, let (url, browser) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: url)
} else {
UIApplication.shared.open(url)
}
}
})
}
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
var linkURL: URL?
var browser: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let url = attrs[linkAttrKey] as? NSURL {
linkURL = url.absoluteURL
browser = attrs[webLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
showSecrets.wrappedValue.remove(i)
} else {
showSecrets.wrappedValue.insert(i)
}
}
stop.pointee = true
}
}
return if let linkURL { (linkURL, browser) } else { nil }
}
}
func hiddenSecretsView<V: View>(_ v: V) -> some View {
v.overlay(
GeometryReader { g in
let size = (g.size.width + g.size.height) / 1.4142
Image("vertical_logo")
.resizable(resizingMode: .tile)
.frame(width: size, height: size)
.rotationEffect(.degrees(45), anchor: .center)
.position(x: g.size.width / 2, y: g.size.height / 2)
.clipped()
.saturation(0.65)
.opacity(0.35)
}
.mask(v)
)
}
private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
func messageText(
_ text: String,
_ formattedText: [FormattedText]?,
textStyle: UIFont.TextStyle = .body,
sender: String?,
preview: Bool = false,
mentions: [String: CIMention]?,
userMemberId: String?,
showSecrets: Set<Int>?,
backgroundColor: UIColor,
prefix: NSAttributedString? = nil
) -> MsgTextResult {
let res = NSMutableAttributedString()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = UIFont.preferredFont(forTextStyle: textStyle)
let plain: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.label
]
let secretColor = backgroundColor.withAlphaComponent(1)
var link: [NSAttributedString.Key: Any]?
var hasSecrets = false
var handleTaps = false
if let sender {
if preview {
res.append(NSAttributedString(string: sender + ": ", attributes: plain))
} else {
var attrs = plain
attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize)
res.append(NSAttributedString(string: sender, attributes: attrs))
res.append(NSAttributedString(string: ": ", attributes: plain))
}
}
if let prefix {
res.append(prefix)
}
if let fts = formattedText, fts.count > 0 {
var bold: UIFont?
var italic: UIFont?
var snippet: UIFont?
var mention: UIFont?
var secretIdx: Int = 0
for ft in fts {
var t = ft.text
var attrs = plain
switch (ft.format) {
case .bold:
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
attrs[.font] = bold
case .italic:
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
case .strikeThrough:
attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
case .snippet:
snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
attrs[.font] = snippet
case .secret:
if let showSecrets {
if !showSecrets.contains(secretIdx) {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
attrs[secretAttrKey] = secretIdx
secretIdx += 1
handleTaps = true
} else {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
hasSecrets = true
case let .colored(color):
if let c = color.uiColor {
attrs[.foregroundColor] = UIColor(c)
}
case .uri:
attrs = linkAttrs()
if !preview {
let s = t.lowercased()
let link = s.hasPrefix("http://") || s.hasPrefix("https://")
? t
: "https://" + t
attrs[linkAttrKey] = NSURL(string: link)
attrs[webLinkAttrKey] = true
handleTaps = true
}
case let .simplexLink(linkType, simplexUri, smpHosts):
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: simplexUri)
handleTaps = true
}
if case .description = privacySimplexLinkModeDefault.get() {
t = simplexLinkText(linkType, smpHosts)
}
case let .mention(memberName):
if let m = mentions?[memberName] {
mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize)
attrs[.font] = mention
if let ref = m.memberRef {
let name: String = if let alias = ref.localAlias, alias != "" {
"\(alias) (\(ref.displayName))"
} else {
ref.displayName
}
if m.memberId == userMemberId {
attrs[.foregroundColor] = UIColor.tintColor
}
t = mentionText(name)
} else {
t = mentionText(memberName)
}
}
case .email:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
handleTaps = true
}
case .phone:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
handleTaps = true
}
case .none: ()
}
res.append(NSAttributedString(string: t, attributes: attrs))
} }
} else { } else {
res.append(NSMutableAttributedString(string: text, attributes: plain)) res = Text(s)
} }
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
func linkAttrs() -> [NSAttributedString.Key: Any] { if let i = icon {
link = link ?? [ res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
.font: font, }
.foregroundColor: uiLinkColor,
.underlineStyle: NSUnderlineStyle.single.rawValue if let s = sender {
] let t = Text(s)
return link! return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res
} else {
return res
} }
} }
@inline(__always) private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
private func mentionText(_ name: String) -> String { let t = ft.text
name.contains(" @") ? "@'\(name)'" : "@\(name)" 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: "")
}
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)
}
}
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 simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
@ -371,9 +165,9 @@ struct MsgContentView_Previews: PreviewProvider {
chat: Chat.sampleData, chat: Chat.sampleData,
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
textStyle: .body,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
meta: chatItem.meta meta: chatItem.meta,
showSecrets: false
) )
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }

View file

@ -14,13 +14,15 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
var chatItems: [ChatItem] var ci: ChatItem
var fromChatInfo: ChatInfo var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert? @State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) @State private var hasSimplexLink_: Bool?
private let chatsToForwardTo = filterChatsToForwardTo()
var body: some View { var body: some View {
NavigationView { NavigationView {
@ -41,10 +43,12 @@ struct ChatItemForwardingView: View {
.alert(item: $alert) { $0.alert } .alert(item: $alert) { $0.alert }
} }
private func forwardListView() -> some View { @ViewBuilder private func forwardListView() -> some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty { if !chatsToForwardTo.isEmpty {
List { List {
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) } let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in ForEach(chats) { chat in
@ -52,7 +56,6 @@ struct ChatItemForwardingView: View {
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
} }
} }
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} else { } else {
ZStack { ZStack {
@ -64,6 +67,50 @@ struct ChatItemForwardingView: View {
} }
} }
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
let cInfo = chat.chatInfo
return switch cInfo {
case let .direct(contact):
viewNameContains(cInfo, searchStr) ||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
contact.fullName.localizedLowercase.contains(searchStr)
default:
viewNameContains(cInfo, searchStr)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
private func prohibitedByPref(_ chat: Chat) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
return switch chat.chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
private var hasSimplexLink: Bool {
if let hasSimplexLink_ { return hasSimplexLink_ }
let r =
if let mcText = ci.content.msgContent?.text,
let parsedMsg = parseSimpleXMarkdown(mcText) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
hasSimplexLink_ = r
return r
}
private func emptyList() -> some View { private func emptyList() -> some View {
Text("No filtered chats") Text("No filtered chats")
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
@ -71,14 +118,7 @@ struct ChatItemForwardingView: View {
} }
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chatItems.map { ci in let prohibited = prohibitedByPref(chat)
chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
}.contains(true)
Button { Button {
if prohibited { if prohibited {
alert = SomeAlert( alert = SomeAlert(
@ -94,11 +134,11 @@ struct ChatItemForwardingView: View {
composeState = ComposeState( composeState = ComposeState(
message: composeState.message, message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo) contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
) )
} else { } else {
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id) chatModel.chatId = chat.id
} }
} }
} label: { } label: {
@ -122,11 +162,31 @@ struct ChatItemForwardingView: View {
} }
} }
private func filterChatsToForwardTo() -> [Chat] {
var filteredChats = ChatModel.shared.chats.filter { c in
c.chatInfo.chatType != .local && canForwardToChat(c)
}
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
filteredChats.insert(privateNotes, at: 0)
}
return filteredChats
}
private func canForwardToChat(_ chat: Chat) -> Bool {
switch chat.chatInfo {
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
case let .group(groupInfo): groupInfo.sendMsgEnabled
case let .local(noteFolder): noteFolder.sendMsgEnabled
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
#Preview { #Preview {
ChatItemForwardingView( ChatItemForwardingView(
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")], ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
fromChatInfo: .direct(contact: Contact.sampleData), fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello")) composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme()) ).environmentObject(CurrentColors.toAppTheme())
} }

View file

@ -14,7 +14,6 @@ struct ChatItemInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var ci: ChatItem var ci: ChatItem
var userMemberId: String?
@Binding var chatItemInfo: ChatItemInfo? @Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history @State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil @State private var alert: CIInfoViewAlert? = nil
@ -131,9 +130,9 @@ struct ChatItemInfoView: View {
} }
} }
private func details() -> some View { @ViewBuilder private func details() -> some View {
let meta = ci.meta let meta = ci.meta
return VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text(title) Text(title)
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
@ -197,7 +196,7 @@ struct ChatItemInfoView: View {
} }
} }
private func historyTab() -> some View { @ViewBuilder private func historyTab() -> some View {
GeometryReader { g in GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84 let maxWidth = (g.size.width - 32) * 0.84
ScrollView { ScrollView {
@ -227,13 +226,12 @@ struct ChatItemInfoView: View {
} }
} }
private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
let backgroundColor = chatItemFrameColor(ci, theme) VStack(alignment: .leading, spacing: 4) {
return VStack(alignment: .leading, spacing: 4) { textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(backgroundColor) .background(chatItemFrameColor(ci, theme))
.modifier(ChatItemClipped()) .modifier(ChatItemClipped())
.contextMenu { .contextMenu {
if itemVersion.msgContent.text != "" { if itemVersion.msgContent.text != "" {
@ -258,9 +256,9 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading) .frame(maxWidth: maxWidth, alignment: .leading)
} }
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View { @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
if text != "" { if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) TextBubble(text: text, formattedText: formattedText, sender: sender)
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
@ -273,18 +271,14 @@ struct ChatItemInfoView: View {
var text: String var text: String
var formattedText: [FormattedText]? var formattedText: [FormattedText]?
var sender: String? = nil var sender: String? = nil
var mentions: [String: CIMention]? @State private var showSecrets = false
var userMemberId: String?
var backgroundColor: UIColor
@State private var showSecrets: Set<Int> = []
var body: some View { var body: some View {
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor) toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
} }
} }
private func quoteTab(_ qi: CIQuote) -> some View { @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
GeometryReader { g in GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84 let maxWidth = (g.size.width - 32) * 0.84
ScrollView { ScrollView {
@ -302,10 +296,9 @@ struct ChatItemInfoView: View {
} }
} }
private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
let backgroundColor = quotedMsgFrameColor(qi, theme) VStack(alignment: .leading, spacing: 4) {
return VStack(alignment: .leading, spacing: 4) { textBubble(qi.text, qi.formattedText, qi.getSender(nil))
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme)) .background(quotedMsgFrameColor(qi, theme))
@ -338,7 +331,7 @@ struct ChatItemInfoView: View {
: theme.appColors.receivedMessage : theme.appColors.receivedMessage
} }
private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
details() details()
@ -358,9 +351,8 @@ struct ChatItemInfoView: View {
Button { Button {
Task { Task {
await MainActor.run { await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) { chatModel.chatId = forwardedFromItem.chatInfo.id
dismiss() dismiss()
}
} }
} }
} label: { } label: {
@ -376,7 +368,7 @@ struct ChatItemInfoView: View {
} }
} }
private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack { HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48) ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6) .padding(.trailing, 6)
@ -407,7 +399,7 @@ struct ChatItemInfoView: View {
} }
} }
private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
details() details()
@ -422,7 +414,7 @@ struct ChatItemInfoView: View {
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
} }
private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) { LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses) let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty { if !mss.isEmpty {
@ -448,7 +440,7 @@ struct ChatItemInfoView: View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View { private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
HStack{ HStack{
MemberProfileImage(member, size: 30) ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2) .padding(.trailing, 2)
Text(member.chatViewName) Text(member.chatViewName)
.lineLimit(1) .lineLimit(1)
@ -458,8 +450,20 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67) .foregroundColor(theme.colors.secondary).opacity(0.67)
} }
let v = Group { let v = Group {
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
image.foregroundColor(statusColor) switch status {
case .rcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
} }
if let (title, text) = status.statusInfo { if let (title, text) = status.statusInfo {
@ -556,6 +560,6 @@ func localTimestamp(_ date: Date) -> String {
struct ChatItemInfoView_Previews: PreviewProvider { struct ChatItemInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
} }
} }

View file

@ -9,71 +9,48 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
struct Revealed: EnvironmentKey {
static let defaultValue: Bool = true
}
struct ContainerBackground: EnvironmentKey {
static let defaultValue: UIColor = .clear
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
var revealed: Bool {
get { self[Revealed.self] }
set { self[Revealed.self] = newValue }
}
var containerBackground: UIColor {
get { self[ContainerBackground.self] }
set { self[ContainerBackground.self] = newValue }
}
}
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem var chatItem: ChatItem
var scrollToItemId: (ChatItem.ID) -> Void
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init( init(
chat: Chat, chat: Chat,
chatItem: ChatItem, chatItem: ChatItem,
scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false, showMember: Bool = false,
maxWidth: CGFloat = .infinity, maxWidth: CGFloat = .infinity,
allowMenu: Binding<Bool> = .constant(false) revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) { ) {
self.chat = chat self.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu _allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
} }
var body: some View { var body: some View {
let ci = chatItem let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem) MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { } 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) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci) EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } 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) CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil { } 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, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
framedItemView() framedItemView()
} }
@ -91,7 +68,7 @@ struct ChatItemView: View {
default: nil default: nil
} }
} }
.flatMap { imageFromBase64($0) } .flatMap { UIImage(base64Encoded: $0) }
let adjustedMaxWidth = { let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height { if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75 maxWidth * 0.75
@ -102,12 +79,15 @@ struct ChatItemView: View {
return FramedItemView( return FramedItemView(
chat: chat, chat: chat,
chatItem: chatItem, chatItem: chatItem,
scrollToItemId: scrollToItemId,
preview: preview, preview: preview,
revealed: $revealed,
maxWidth: maxWidth, maxWidth: maxWidth,
imgWidth: adjustedMaxWidth, imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth, videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
) )
} }
} }
@ -115,9 +95,9 @@ struct ChatItemView: View {
struct ChatItemContentView<Content: View>: View { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var msgContentView: () -> Content var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@ -149,7 +129,7 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatPreference(feature, allowed, param): case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _): case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, 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 .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 .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@ -183,7 +163,7 @@ struct ChatItemContentView<Content: View>: View {
private func eventItemViewText(_ secondaryColor: Color) -> Text { private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText { if !revealed, let t = mergedGroupEventText {
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor) return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
} else if let member = chatItem.memberDisplayName { } else if let member = chatItem.memberDisplayName {
return Text(member + " ") return Text(member + " ")
.font(.caption) .font(.caption)
@ -196,7 +176,7 @@ struct ChatItemContentView<Content: View>: View {
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
} }
private var mergedGroupEventText: Text? { private var mergedGroupEventText: Text? {
@ -216,7 +196,7 @@ struct ChatItemContentView<Content: View>: View {
} else if ns.count == 0 { } else if ns.count == 0 {
Text("\(count) group events") Text("\(count) group events")
} else if count > ns.count { } else if count > ns.count {
Text(members) + textSpace + Text("and \(count - ns.count) other events") Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
} else { } else {
Text(members) Text(members)
} }
@ -247,7 +227,7 @@ func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
chatEventText(Text(eventText) + textSpace + ts, secondaryColor) chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
} }
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
@ -257,17 +237,16 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
@ -286,7 +265,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
), ),
scrollToItemId: { _ in } revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@ -297,7 +276,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
), ),
scrollToItemId: { _ in } revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@ -308,7 +287,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
), ),
scrollToItemId: { _ in } revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@ -319,7 +298,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
), ),
scrollToItemId: { _ in } revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@ -330,10 +309,9 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
), ),
scrollToItemId: { _ in } revealed: Binding.constant(true)
) )
} }
.environment(\.revealed, true)
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }

View file

@ -1,511 +0,0 @@
//
// ChatItemsLoader.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 17.12.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SimpleXChat
import SwiftUI
let TRIM_KEEP_COUNT = 200
func apiLoadMessages(
_ chatId: ChatId,
_ pagination: ChatPagination,
_ chatState: ActiveChatState,
_ search: String = "",
_ openAroundItemId: ChatItem.ID? = nil,
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
) async {
let chat: Chat
let navInfo: NavigationInfo
do {
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
}
let chatModel = ChatModel.shared
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
let paginationIsInitial = switch pagination { case .initial: true; default: false }
let paginationIsLast = switch pagination { case .last: true; default: false }
// When openAroundItemId is provided, chatId can be different too
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled {
return
}
let unreadAfterItemId = chatState.unreadAfterItemId
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
var newItems: [ChatItem] = []
switch pagination {
case .initial:
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
if chatModel.getChat(chat.id) == nil {
chatModel.addChat(chat)
}
await MainActor.run {
chatModel.chatItemStatuses.removeAll()
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
chatModel.updateChatInfo(chat.chatInfo)
chatState.splits = newSplits
if !chat.chatItems.isEmpty {
chatState.unreadAfterItemId = chat.chatItems.last!.id
}
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
PreloadState.shared.clear()
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
guard let indexInCurrentItems else { return }
let (newIds, _) = mapItemsToIds(chat.chatItems)
let wasSize = newItems.count
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
)
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = modifiedSplits.newSplits
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
}
case let .after(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
guard let indexInCurrentItems else { return }
let mappedItems = mapItemsToIds(chat.chatItems)
let newIds = mappedItems.0
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
)
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
let indexToAddIsLast = indexToAdd == newItems.count
newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
let new: [ChatItem] = newItems
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = newSplits
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
// loading clear bottom area, updating number of unread items after the newest loaded item
if indexToAddIsLast {
chatState.unreadAfterNewestLoaded -= unreadInLoaded
}
}
case .around:
var newSplits: [Int64]
if openAroundItemId == nil {
newItems.append(contentsOf: oldItems)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
} else {
newSplits = []
}
let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
//indexToInsertAroundTest()
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
let newReversed: [ChatItem] = newItems.reversed()
let orderedSplits = newSplits
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = orderedSplits
chatState.unreadAfterItemId = chat.chatItems.last!.id
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
if let openAroundItemId {
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
ChatModel.shared.openAroundItemId = openAroundItemId
ChatModel.shared.chatId = chatId
} else {
// no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
PreloadState.shared.clear()
}
case .last:
newItems.append(contentsOf: oldItems)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
newItems.append(contentsOf: chat.chatItems)
let items = newItems
await MainActor.run {
ItemsModel.shared.reversedChatItems = items.reversed()
chatState.splits = newSplits
chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0
}
}
}
private class ModifiedSplits {
let oldUnreadSplitIndex: Int
let newUnreadSplitIndex: Int
let trimmedIds: Set<Int64>
let newSplits: [Int64]
init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set<Int64>, newSplits: [Int64]) {
self.oldUnreadSplitIndex = oldUnreadSplitIndex
self.newUnreadSplitIndex = newUnreadSplitIndex
self.trimmedIds = trimmedIds
self.newSplits = newSplits
}
}
private func removeDuplicatesAndModifySplitsOnBeforePagination(
_ unreadAfterItemId: Int64,
_ newItems: inout [ChatItem],
_ newIds: Set<Int64>,
_ splits: [Int64],
_ visibleItemIndexes: ClosedRange<Int>
) -> 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<Int64>()
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<Int64>,
_ chat: Chat,
_ splits: [Int64]
) -> ([Int64], Int) {
var unreadInLoaded = unreadInLoaded
var firstItemIdBelowAllSplits: Int64? = nil
var splitsToRemove: Set<Int64> = []
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<Int>
) 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<Set<Int64>>] = []
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<Int64>, Int) {
var unreadInLoaded = 0
var ids: Set<Int64> = 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<Int64>) -> (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))
}

View file

@ -1,456 +0,0 @@
//
// ChatItemsMerger.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 02.12.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct MergedItems: Hashable, Equatable {
let items: [MergedItem]
let splits: [SplitRange]
// chat item id, index in list
let indexInParentItems: Dictionary<Int64, Int>
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hashValue == rhs.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine("\(items.hashValue)")
}
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
if items.isEmpty {
return MergedItems(items: [], splits: [], indexInParentItems: [:])
}
let unreadCount = chatState.unreadTotal
let unreadAfterItemId = chatState.unreadAfterItemId
let itemSplits = chatState.splits
var mergedItems: [MergedItem] = []
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
var splitRanges: [SplitRange] = []
var indexInParentItems = Dictionary<Int64, Int>()
var index = 0
var unclosedSplitIndex: Int? = nil
var unclosedSplitIndexInParent: Int? = nil
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
var recent: MergedItem? = nil
while index < items.count {
let item = items[index]
let prev = index >= 1 ? items[index - 1] : nil
let next = index + 1 < items.count ? items[index + 1] : nil
let category = item.mergeCategory
let itemIsSplit = itemSplits.contains(item.id)
if item.id == unreadAfterItemId {
unreadBefore = unreadCount - chatState.unreadAfter
}
if item.isRcvNew {
unreadBefore -= 1
}
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
items.boxedValue.append(listItem)
if item.isRcvNew {
unreadIds.boxedValue.insert(item.id)
}
if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
if revealed {
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
}
lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
}
} else {
visibleItemIndexInParent += 1
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
if item.mergeCategory != nil {
if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : [])
} else if revealed, let lastRevealedIdsInMergedItems {
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
}
lastRangeInReversedForMergedItems = BoxedValue(index ... index)
recent = MergedItem.grouped(
items: BoxedValue([listItem]),
revealed: revealed,
revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
rangeInReversed: lastRangeInReversedForMergedItems!,
mergeCategory: item.mergeCategory,
unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()),
startIndexInReversedItems: index,
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
)
} else {
lastRangeInReversedForMergedItems = nil
recent = MergedItem.single(
item: listItem,
startIndexInReversedItems: index,
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
)
}
mergedItems.append(recent!)
}
if itemIsSplit {
// found item that is considered as a split
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
// it was at least second split in the list
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
// just one split for the whole list, there will be no more, it's the end
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index += 1
}
return MergedItems(
items: mergedItems,
splits: splitRanges,
indexInParentItems: indexInParentItems
)
}
// Use this check to ensure that mergedItems state based on currently actual state of global
// splits and reversedChatItems
func isActualState() -> Bool {
let im = ItemsModel.shared
// do not load anything if global splits state is different than in merged items because it
// will produce undefined results in terms of loading and placement of items.
// Same applies to reversedChatItems
return indexInParentItems.count == im.reversedChatItems.count &&
splits.count == im.chatState.splits.count &&
// that's just an optimization because most of the time only 1 split exists
((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted())
}
}
enum MergedItem: Identifiable, Hashable, Equatable {
// equatable and hashable implementations allows to see the difference and correctly scroll to items we want
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hash == rhs.hash
}
var id: Int64 { newest().item.id }
func hash(into hasher: inout Hasher) {
hasher.combine(hash)
}
var hash: String {
switch self {
case .single(_, _, let hash): hash + " 1"
case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)"
}
}
// the item that is always single, cannot be grouped and always revealed
case single(
item: ListItem,
startIndexInReversedItems: Int,
hash: String
)
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
* visible items in ChatView's EndlessScrollView */
case grouped (
items: BoxedValue<[ListItem]>,
revealed: Bool,
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
// it's the same list instance for all Grouped items within revealed group
/** @see reveal */
revealedIdsWithinGroup: BoxedValue<[Int64]>,
rangeInReversed: BoxedValue<ClosedRange<Int>>,
mergeCategory: CIMergeCategory?,
unreadIds: BoxedValue<Set<Int64>>,
startIndexInReversedItems: Int,
hash: String
)
func revealItems(_ reveal: Bool, _ revealedItems: Binding<Set<Int64>>) {
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<Int>
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
let indexRangeInParentItems: ClosedRange<Int>
}
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..<newIndex + 1].filter { $0.isRcvNew }.count
: nonReversedItems[newIndex + 1..<currentIndex + 1].filter { $0.isRcvNew }.count
unreadAfter += unreadDiff
}
func moveUnreadAfterItem(_ fromIndex: Int, _ toIndex: Int, _ nonReversedItems: [ChatItem]) {
if fromIndex == -1 || toIndex == -1 {
return
}
unreadAfterItemId = nonReversedItems[toIndex].id
let unreadDiff = toIndex > fromIndex
? -nonReversedItems[fromIndex + 1..<toIndex + 1].filter { $0.isRcvNew }.count
: nonReversedItems[toIndex + 1..<fromIndex + 1].filter { $0.isRcvNew }.count
unreadAfter += unreadDiff
}
func clear() {
splits = []
unreadAfterItemId = -1
totalAfter = 0
unreadTotal = 0
unreadAfter = 0
unreadAfterNewestLoaded = 0
}
func itemsRead(_ itemIds: Set<Int64>?, _ 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<T: Hashable>: Equatable, Hashable {
static func == (lhs: BoxedValue<T>, rhs: BoxedValue<T>) -> Bool {
lhs.boxedValue == rhs.boxedValue
}
func hash(into hasher: inout Hasher) {
hasher.combine("\(self)")
}
var boxedValue : T
init(_ value: T) {
self.boxedValue = value
}
}
@MainActor
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
let zero = 0 ... 0
let items = mergedItems.items
if items.isEmpty {
return zero
}
let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
guard let newest, let oldest else {
return zero
}
let size = ItemsModel.shared.reversedChatItems.count
let range = size - oldest ... size - newest
if range.lowerBound < 0 || range.upperBound < 0 {
return zero
}
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
return range
}

View file

@ -1,185 +0,0 @@
//
// 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<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
await MainActor.run {
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
}
try? await Task.sleep(nanoseconds: 500_000000)
if ChatModel.shared.chatId != chat.chatInfo.id {
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
return
}
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
}
class PreloadState {
static let shared = PreloadState()
var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0
var preloading: Bool = false
func clear() {
prevFirstVisible = Int64.min
prevItemsCount = 0
preloading = false
}
}
func preloadIfNeeded(
_ allowLoadMoreItems: Binding<Bool>,
_ ignoreLoadingRequests: Binding<Int64?>,
_ listState: EndlessScrollView<MergedItem>.ListState,
_ mergedItems: BoxedValue<MergedItems>,
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) {
let state = PreloadState.shared
guard !listState.isScrolling && !listState.isAnimatedScrolling,
!state.preloading,
listState.totalItemsCount > 0
else {
return
}
if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
state.preloading = true
let allowLoadMore = allowLoadMoreItems.wrappedValue
Task {
defer { state.preloading = false }
var triedToLoad = true
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
if triedToLoad {
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
}
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
// split will be merged with last items and position of scroll will change unexpectedly.
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
await loadLastItems()
}
}
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
await loadLastItems()
}
}
}
func preloadItems(
_ mergedItems: MergedItems,
_ allowLoadMoreItems: Bool,
_ listState: EndlessScrollView<MergedItem>.ListState,
_ ignoreLoadingRequests: Binding<Int64?>,
_ loadItems: @escaping (ChatPagination) async -> Bool)
async {
let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1
let remaining = ChatPagination.UNTIL_PRELOAD_COUNT
let firstVisibleIndex = listState.firstVisibleItemIndex
if !(await preloadItemsBefore()) {
await preloadItemsAfter()
}
func preloadItemsBefore() async -> Bool {
let splits = mergedItems.splits
let lastVisibleIndex = listState.lastVisibleItemIndex
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
lastIndexToLoadFrom = items.count - 1
}
let loadFromItemId: Int64?
if allowLoad, let lastIndexToLoadFrom {
let index = items.count - 1 - lastIndexToLoadFrom
loadFromItemId = index >= 0 ? items[index].id : nil
} else {
loadFromItemId = nil
}
guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else {
return false
}
let sizeWas = items.count
let firstItemIdWas = items.first?.id
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
}
return triedToLoad
}
func preloadItemsAfter() async {
let splits = mergedItems.splits
let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
let index = split.indexRangeInReversed.lowerBound
if index >= 0 {
let loadFromItemId = reversedItems[index].id
_ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
}
}
}
}
func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView<MergedItem>.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<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ struct ComposeImageView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in let imgs: [UIImage] = images.compactMap { image in
imageFromBase64(image) UIImage(base64Encoded: image)
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()

View file

@ -10,6 +10,35 @@ import SwiftUI
import LinkPresentation import LinkPresentation
import SimpleXChat import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View { struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview? let linkPreview: LinkPreview?
@ -18,7 +47,7 @@ struct ComposeLinkView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let linkPreview { if let linkPreview = linkPreview {
linkPreviewView(linkPreview) linkPreviewView(linkPreview)
} else { } else {
ProgressView() ProgressView()
@ -40,7 +69,7 @@ struct ComposeLinkView: View {
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = imageFromBase64(linkPreview.image) { if let uiImage = UIImage(base64Encoded: linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@ -55,7 +84,7 @@ struct ComposeLinkView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
} }
} }
} }

View file

@ -11,8 +11,6 @@ import SimpleXChat
import SwiftyGif import SwiftyGif
import PhotosUI import PhotosUI
let MAX_NUMBER_OF_MENTIONS = 3
enum ComposePreview { enum ComposePreview {
case noPreview case noPreview
case linkPreview(linkPreview: LinkPreview?) case linkPreview(linkPreview: LinkPreview?)
@ -21,12 +19,11 @@ enum ComposePreview {
case filePreview(fileName: String, file: URL) case filePreview(fileName: String, file: URL)
} }
enum ComposeContextItem: Equatable { enum ComposeContextItem {
case noContextItem case noContextItem
case quotedItem(chatItem: ChatItem) case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem)
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
case reportedItem(chatItem: ChatItem, reason: ReportReason)
} }
enum VoiceMessageRecordingState { enum VoiceMessageRecordingState {
@ -41,41 +38,31 @@ struct LiveMessage {
var sentMsg: String? var sentMsg: String?
} }
typealias MentionedMembers = [String: CIMention]
struct ComposeState { struct ComposeState {
var message: String var message: String
var parsedMessage: [FormattedText]
var liveMessage: LiveMessage? = nil var liveMessage: LiveMessage? = nil
var preview: ComposePreview var preview: ComposePreview
var contextItem: ComposeContextItem var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false var inProgress = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
var mentions: MentionedMembers = [:]
init( init(
message: String = "", message: String = "",
parsedMessage: [FormattedText] = [],
liveMessage: LiveMessage? = nil, liveMessage: LiveMessage? = nil,
preview: ComposePreview = .noPreview, preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem, contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
mentions: MentionedMembers = [:]
) { ) {
self.message = message self.message = message
self.parsedMessage = parsedMessage
self.liveMessage = liveMessage self.liveMessage = liveMessage
self.preview = preview self.preview = preview
self.contextItem = contextItem self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState self.voiceMessageRecordingState = voiceMessageRecordingState
self.mentions = mentions
} }
init(editingItem: ChatItem) { init(editingItem: ChatItem) {
let text = editingItem.content.text self.message = editingItem.content.text
self.message = text
self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
self.preview = chatItemPreview(chatItem: editingItem) self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem)
if let emc = editingItem.content.msgContent, if let emc = editingItem.content.msgContent,
@ -84,51 +71,31 @@ struct ComposeState {
} else { } else {
self.voiceMessageRecordingState = .noRecording self.voiceMessageRecordingState = .noRecording
} }
self.mentions = editingItem.mentions ?? [:]
} }
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
self.message = "" self.message = ""
self.parsedMessage = []
self.preview = .noPreview self.preview = .noPreview
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording self.voiceMessageRecordingState = .noRecording
} }
func copy( func copy(
message: String? = nil, message: String? = nil,
parsedMessage: [FormattedText]? = nil,
liveMessage: LiveMessage? = nil, liveMessage: LiveMessage? = nil,
preview: ComposePreview? = nil, preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil, contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil, voiceMessageRecordingState: VoiceMessageRecordingState? = nil
mentions: MentionedMembers? = nil
) -> ComposeState { ) -> ComposeState {
ComposeState( ComposeState(
message: message ?? self.message, message: message ?? self.message,
parsedMessage: parsedMessage ?? self.parsedMessage,
liveMessage: liveMessage ?? self.liveMessage, liveMessage: liveMessage ?? self.liveMessage,
preview: preview ?? self.preview, preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem, 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 { var editing: Bool {
switch contextItem { switch contextItem {
case .editingItem: return true case .editingItem: return true
@ -145,35 +112,17 @@ struct ComposeState {
var forwarding: Bool { var forwarding: Bool {
switch contextItem { switch contextItem {
case .forwardingItems: return true case .forwardingItem: return true
default: return false default: return false
} }
} }
var reporting: Bool {
switch contextItem {
case .reportedItem: return true
default: return false
}
}
var submittingValidReport: Bool {
switch contextItem {
case let .reportedItem(_, reason):
switch reason {
case .other: return !message.isEmpty
default: return true
}
default: return false
}
}
var sendEnabled: Bool { var sendEnabled: Bool {
switch preview { switch preview {
case let .mediaPreviews(media): return !media.isEmpty case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true case .filePreview: return true
default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport default: return !message.isEmpty || forwarding || liveMessage != nil
} }
} }
@ -218,15 +167,8 @@ struct ComposeState {
} }
} }
var manyMediaPreviews: Bool {
switch preview {
case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
default: return false
}
}
var attachmentDisabled: Bool { var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress || reporting { return true } if editing || forwarding || liveMessage != nil || inProgress { return true }
switch preview { switch preview {
case .noPreview: return false case .noPreview: return false
case .linkPreview: return false case .linkPreview: return false
@ -244,15 +186,6 @@ struct ComposeState {
} }
} }
var placeholder: String? {
switch contextItem {
case let .reportedItem(_, reason):
return reason.text
default:
return nil
}
}
var empty: Bool { var empty: Bool {
message == "" && noPreview message == "" && noPreview
} }
@ -325,9 +258,6 @@ struct ComposeView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var keyboardVisible: 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: URL? = nil
@State var hasSimplexLink: Bool = false @State var hasSimplexLink: Bool = false
@ -350,8 +280,7 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases // this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -360,11 +289,6 @@ struct ComposeView: View {
ContextInvitingContactMemberView() ContextInvitingContactMemberView()
Divider() Divider()
} }
if case let .reportedItem(_, reason) = composeState.contextItem {
reportReasonView(reason)
Divider()
}
// preference checks should match checks in forwarding list // preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
@ -392,9 +316,9 @@ struct ComposeView: View {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.resizable() .resizable()
} }
.disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 16) .padding(.bottom, 12)
.padding(.leading, 12) .padding(.leading, 12)
.tint(theme.colors.primary) .tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo, if case let .group(g) = chat.chatInfo,
@ -411,7 +335,6 @@ struct ComposeView: View {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
SendMessageView( SendMessageView(
composeState: $composeState, composeState: $composeState,
selectedRange: $selectedRange,
sendMessage: { ttl in sendMessage: { ttl in
sendMessage(ttl: ttl) sendMessage(ttl: ttl)
resetLinkPreview() resetLinkPreview()
@ -436,46 +359,45 @@ struct ComposeView: View {
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
sendButtonColor: chat.chatInfo.incognito sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: theme.colors.primary : theme.colors.primary
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.disabled(!chat.chatInfo.sendMsgEnabled) .disabled(!chat.userCanSend)
if let disabledText { if chat.userIsObserver {
Text(disabledText) Text("you are observer")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
} }
} }
} }
} }
.background { .background(.thinMaterial)
Color.clear
.overlay(ToolbarMaterial.material(toolbarMaterial))
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in .onChange(of: composeState.message) { msg in
let parsedMsg = parseSimpleXMarkdown(msg)
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
if composeState.linkPreviewAllowed { if composeState.linkPreviewAllowed {
if msg.count > 0 { if msg.count > 0 {
showLinkPreview(parsedMsg) showLinkPreview(msg)
} else { } else {
resetLinkPreview() resetLinkPreview()
hasSimplexLink = false hasSimplexLink = false
} }
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
(_, hasSimplexLink) = getSimplexLink(parsedMsg) (_, hasSimplexLink) = parseMessage(msg)
} else { } else {
hasSimplexLink = false hasSimplexLink = false
} }
} }
.onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in .onChange(of: chat.userCanSend) { canSend in
if !sendEnabled { if !canSend {
cancelCurrentVoiceRecording() cancelCurrentVoiceRecording()
clearCurrentDraft() clearCurrentDraft()
clearState() clearState()
@ -525,7 +447,7 @@ struct ComposeView: View {
Task { Task {
var media: [(String, UploadContent)] = [] var media: [(String, UploadContent)] = []
for content in selected { for content in selected {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
media.append((img, content)) media.append((img, content))
await MainActor.run { await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media)) composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
@ -617,7 +539,7 @@ struct ComposeView: View {
} }
private func addMediaContent(_ content: UploadContent) async { private func addMediaContent(_ content: UploadContent) async {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = [] var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview { if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content)) media.append((img, content))
@ -752,27 +674,6 @@ struct ComposeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial) .background(.thinMaterial)
} }
private func reportReasonView(_ reason: ReportReason) -> some View {
let reportText = switch reason {
case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
case .unknown: "" // Should never happen
}
return Text(reportText)
.italic()
.font(.caption)
.padding(12)
.frame(minHeight: 44)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
@ViewBuilder private func contextItemView() -> some View { @ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem { switch composeState.contextItem {
@ -781,7 +682,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [quotedItem], contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
@ -789,26 +690,18 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [editingItem], contextItem: editingItem,
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider() Divider()
case let .forwardingItems(chatItems, _): case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: chatItems, contextItem: forwardedItem,
contextIcon: "arrowshape.turn.up.forward", contextIcon: "arrowshape.turn.up.forward",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
case let .reportedItem(chatItem: reportedItem, _):
ContextItemView(
chat: chat,
contextItems: [reportedItem],
contextIcon: "flag",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
contextIconForeground: Color.red showSender: false
) )
Divider() Divider()
} }
@ -826,25 +719,21 @@ struct ComposeView: View {
var sent: ChatItem? var sent: ChatItem?
let msgText = text ?? composeState.message let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage let liveMessage = composeState.liveMessage
let mentions = composeState.memberMentions
if !live { if !live {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) } if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending() await sending()
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation() await sendMemberContactInvitation()
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem { } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
// Composed text is send as a reply to the last forwarded item sent = await forwardItem(ci, fromChatInfo, ttl)
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty { if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions) sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
} }
} else if case let .editingItem(ci) = composeState.contextItem { } else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live) sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live) sent = await updateMessage(liveMessage.chatItem, live: live)
} else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
sent = await send(reason, chatItemId: chatItem.id)
} else { } else {
var quoted: Int64? = nil var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
@ -853,39 +742,36 @@ struct ComposeView: View {
switch (composeState.preview) { switch (composeState.preview) {
case .noPreview: case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview: case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(media): case let .mediaPreviews(mediaPreviews: media):
// TODO: CHECK THIS
let last = media.count - 1 let last = media.count - 1
var msgs: [ComposedMessage] = []
if last >= 0 { if last >= 0 {
for i in 0..<last { for i in 0..<last {
if i > 0 { if case (_, .video(_, _, _)) = media[i] {
// Sleep to allow `progressByTimeout` update be rendered sent = await sendVideo(media[i], ttl: ttl)
try? await Task.sleep(nanoseconds: 100_000000) } else {
} sent = await sendImage(media[i], ttl: ttl)
if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
} }
_ = try? await Task.sleep(nanoseconds: 100_000000)
} }
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) { if case (_, .video(_, _, _)) = media[last] {
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent)) sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} }
} }
if msgs.isEmpty { if sent == nil {
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))] sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
} }
sent = await send(msgs, live: live, ttl: ttl).last
case let .voicePreview(recordingFileName, duration): case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle() stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName) let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions) sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
case let .filePreview(_, file): case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) { if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions) sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
} }
} }
@ -900,20 +786,6 @@ struct ComposeView: View {
} }
return sent return sent
func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
let (previewImage, uploadContent) = media
return switch uploadContent {
case let .simpleImage(image):
(saveImage(image), .image(text: text, image: previewImage))
case let .animatedImage(image):
(saveAnimImage(image), .image(text: text, image: previewImage))
case let .video(_, url, duration):
(moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
case .none:
nil
}
}
func sending() async { func sending() async {
await MainActor.run { composeState.inProgress = true } await MainActor.run { composeState.inProgress = true }
} }
@ -940,7 +812,7 @@ struct ComposeView: View {
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
itemId: ei.id, itemId: ei.id,
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), msg: mc,
live: live live: live
) )
await MainActor.run { await MainActor.run {
@ -972,13 +844,28 @@ struct ComposeView: View {
return .voice(text: msgText, duration: duration) return .voice(text: msgText, duration: duration)
case .file: case .file:
return .file(msgText) return .file(msgText)
case .report(_, let reason):
return .report(text: msgText, reason: reason)
case .unknown(let type, _): case .unknown(let type, _):
return .unknown(type: type, text: msgText) return .unknown(type: type, text: msgText)
} }
} }
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if let data = data, let savedFile = saveAnyImage(data) {
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func voiceCryptoFile(_ fileName: String) -> CryptoFile? { func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
if !privacyEncryptLocalFilesGroupDefault.get() { if !privacyEncryptLocalFilesGroupDefault.get() {
return CryptoFile.plain(fileName) return CryptoFile.plain(fileName)
@ -993,93 +880,52 @@ struct ComposeView: View {
return nil return nil
} }
} }
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
if let chatItems = await apiReportMessage(
groupId: chat.chatInfo.apiId,
chatItemId: chatItemId,
reportReason: reportReason,
reportText: msgText
) {
await MainActor.run {
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
return chatItems.first
}
return nil
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
live: live,
ttl: ttl
).first
}
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItems = chat.chatInfo.chatType == .local if let chatItem = chat.chatInfo.chatType == .local
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs) ? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
: await apiSendMessages( : await apiSendMessage(
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live, live: live,
ttl: ttl, ttl: ttl
composedMessages: msgs
) { ) {
await MainActor.run { await MainActor.run {
chatModel.removeLiveDummy(animated: false) chatModel.removeLiveDummy(animated: false)
for chatItem in chatItems { chatModel.addChatItem(chat.chatInfo, chatItem)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} }
return chatItems return chatItem
} }
for msg in msgs { if let file = file {
if let file = msg.fileSource { removeFile(file.filePath)
removeFile(file.filePath)
}
} }
return [] return nil
} }
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] { func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItems = await apiForwardChatItems( if let chatItem = await apiForwardChatItem(
toChatType: chat.chatInfo.chatType, toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId, toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType, fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId, fromChatId: fromChatInfo.apiId,
itemIds: forwardedItems.map { $0.id }, itemId: forwardedItem.id,
ttl: ttl ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
for chatItem in chatItems { chatModel.addChatItem(chat.chatInfo, chatItem)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
if forwardedItems.count != chatItems.count {
showAlert(
String.localizedStringWithFormat(
NSLocalizedString("%d messages not forwarded", comment: "alert title"),
forwardedItems.count - chatItems.count
),
message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
)
}
} }
return chatItems return chatItem
} else {
return []
} }
return nil
} }
func checkLinkPreview() -> MsgContent { func checkLinkPreview() -> MsgContent {
switch (composeState.preview) { switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview): case let .linkPreview(linkPreview: linkPreview):
if let parsedMsg = parseSimpleXMarkdown(msgText), if let url = parseMessage(msgText).url,
let url = getSimplexLink(parsedMsg).url,
let linkPreview = linkPreview, let linkPreview = linkPreview,
url == linkPreview.uri { url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview) return .link(text: msgText, preview: linkPreview)
@ -1090,6 +936,14 @@ struct ComposeView: View {
return .text(msgText) return .text(msgText)
} }
} }
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
default: return nil
}
}
} }
private func startVoiceMessageRecording() async { private func startVoiceMessageRecording() async {
@ -1198,9 +1052,9 @@ struct ComposeView: View {
} }
} }
private func showLinkPreview(_ parsedMsg: [FormattedText]?) { private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl prevLinkUrl = linkUrl
(linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg) (linkUrl, hasSimplexLink) = parseMessage(s)
if let url = linkUrl { if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url pendingLinkUrl = url
@ -1217,8 +1071,8 @@ struct ComposeView: View {
} }
} }
private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) { private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
guard let parsedMsg else { return (nil, false) } guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
let url: URL? = if let uri = parsedMsg.first(where: { ft in let url: URL? = if let uri = parsedMsg.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) { }) {
@ -1249,14 +1103,11 @@ struct ComposeView: View {
if pendingLinkUrl == url { if pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in getLinkPreview(url: url) { linkPreview in
if let linkPreview, pendingLinkUrl == url { if let linkPreview = linkPreview,
pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
} else { pendingLinkUrl = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
composeState = composeState.copy(preview: .noPreview)
}
} }
pendingLinkUrl = nil
} }
} }
} }
@ -1269,27 +1120,26 @@ struct ComposeView: View {
} }
} }
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
struct ComposeView_Previews: PreviewProvider { struct ComposeView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@State var composeState = ComposeState(message: "hello") @State var composeState = ComposeState(message: "hello")
@State var selectedRange = NSRange()
return Group { return Group {
ComposeView( ComposeView(
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true)
keyboardHiddenDate: Binding.constant(Date.now),
selectedRange: $selectedRange
) )
.environmentObject(ChatModel()) .environmentObject(ChatModel())
ComposeView( ComposeView(
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true)
keyboardHiddenDate: Binding.constant(Date.now),
selectedRange: $selectedRange
) )
.environmentObject(ChatModel()) .environmentObject(ChatModel())
} }

View file

@ -12,10 +12,9 @@ import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
let contextItems: [ChatItem] let contextItem: ChatItem
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
var contextIconForeground: Color? = nil
var showSender: Bool = true var showSender: Bool = true
var body: some View { var body: some View {
@ -24,23 +23,14 @@ struct ContextItemView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.foregroundColor(contextIconForeground ?? theme.colors.secondary) .foregroundColor(theme.colors.secondary)
if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = contextItem.memberDisplayName {
if showSender, let sender = singleItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 4) { Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
Text(sender).font(.caption).foregroundColor(theme.colors.secondary) msgContentView(lines: 2)
msgContentView(lines: 2, contextItem: singleItem) }
}
} else {
msgContentView(lines: 3, contextItem: singleItem)
}
} else { } else {
Text( msgContentView(lines: 3)
chat.chatInfo.chatType == .local
? "Saving \(contextItems.count) messages"
: "Forwarding \(contextItems.count) messages"
)
.italic()
} }
Spacer() Spacer()
Button { Button {
@ -55,40 +45,29 @@ struct ContextItemView: View {
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(background) .background(chatItemFrameColor(contextItem, theme))
} }
private var background: Color { private func msgContentView(lines: Int) -> some View {
contextItems.first contextMsgPreview()
.map { chatItemFrameColor($0, theme) }
?? Color(uiColor: .tertiarySystemBackground)
}
private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
contextMsgPreview(contextItem)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines) .lineLimit(lines)
} }
private func contextMsgPreview(_ contextItem: ChatItem) -> some View { private func contextMsgPreview() -> Text {
let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background)) return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
let t = attachment() + Text(AttributedString(r.string))
return t.if(r.hasSecrets, transform: hiddenSecretsView)
func attachment() -> Text { func attachment() -> Text {
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
} else { false }
switch contextItem.content.msgContent { switch contextItem.content.msgContent {
case .file: return isFileLoaded ? image("doc.fill") : Text("") case .file: return image("doc.fill")
case .image: return image("photo") case .image: return image("photo")
case .voice: return isFileLoaded ? image("play.fill") : Text("") case .voice: return image("play.fill")
default: return Text("") default: return Text("")
} }
} }
func image(_ s: String) -> Text { func image(_ s: String) -> Text {
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + textSpace Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
} }
} }
} }
@ -96,6 +75,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider { struct ContextItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
} }
} }

View file

@ -16,15 +16,18 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool @Binding var disableEditing: Bool
@Binding var height: CGFloat @Binding var height: CGFloat
@Binding var focused: Bool @Binding var focused: Bool
@Binding var lastUnfocusedDate: Date
@Binding var placeholder: String?
@Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void let onImagesAdded: ([UploadContent]) -> Void
static let minHeight: CGFloat = 39 private let minHeight: CGFloat = 37
func makeUIView(context: Context) -> CustomUITextField { private let defaultHeight: CGFloat = {
let field = CustomUITextField(parent: self, height: _height) 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)
field.backgroundColor = .clear field.backgroundColor = .clear
field.text = text field.text = text
field.textAlignment = alignment(text) field.textAlignment = alignment(text)
@ -33,9 +36,10 @@ struct NativeTextEditor: UIViewRepresentable {
if !disableEditing { if !disableEditing {
text = newText text = newText
field.textAlignment = alignment(text) field.textAlignment = alignment(text)
field.updateFont() updateFont(field)
// Speed up the process of updating layout, reduce jumping content on screen // Speed up the process of updating layout, reduce jumping content on screen
field.updateHeight() updateHeight(field)
self.height = field.frame.size.height
} else { } else {
field.text = text field.text = text
} }
@ -43,32 +47,42 @@ struct NativeTextEditor: UIViewRepresentable {
onImagesAdded(images) onImagesAdded(images)
} }
} }
field.setOnFocusChangedListener { field.setOnFocusChangedListener { focused = $0 }
focused = $0
if !focused {
lastUnfocusedDate = .now
}
}
field.delegate = field field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
field.setPlaceholderView() updateFont(field)
field.updateFont() updateHeight(field)
field.updateHeight(updateBindingNow: false)
return field return field
} }
func updateUIView(_ field: CustomUITextField, context: Context) { func updateUIView(_ field: UITextView, context: Context) {
if field.markedTextRange == nil && field.text != text { if field.markedTextRange == nil && field.text != text {
field.text = text field.text = text
field.textAlignment = alignment(text) field.textAlignment = alignment(text)
field.updateFont() updateFont(field)
field.updateHeight(updateBindingNow: false) updateHeight(field)
} }
if field.placeholder != placeholder { }
field.placeholder = 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)
} }
if field.selectedRange != selectedRange { }
field.selectedRange = selectedRange
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
} }
} }
} }
@ -77,26 +91,17 @@ private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left isRightToLeft(text) ? .right : .left
} }
class CustomUITextField: UITextView, UITextViewDelegate { private class CustomUITextField: UITextView, UITextViewDelegate {
var parent: NativeTextEditor?
var height: Binding<CGFloat> var height: Binding<CGFloat>
var newHeight: CGFloat = 0 var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in } var onFocusChanged: (Bool) -> Void = { focused in }
private let placeholderLabel: UILabel = UILabel() init(height: Binding<CGFloat>) {
init(parent: NativeTextEditor?, height: Binding<CGFloat>) {
self.parent = parent
self.height = height self.height = height
super.init(frame: .zero, textContainer: nil) super.init(frame: .zero, textContainer: nil)
} }
var placeholder: String? {
get { placeholderLabel.text }
set { placeholderLabel.text = newValue }
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("Not implemented") fatalError("Not implemented")
} }
@ -109,63 +114,16 @@ class CustomUITextField: UITextView, UITextViewDelegate {
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
} }
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
}
}
}
}
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 { override var intrinsicContentSize: CGSize {
CGSizeMake(0, newHeight) if height.wrappedValue != newHeight {
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
}
return CGSizeMake(0, newHeight)
} }
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged self.onTextChanged = onTextChanged
} }
func setPlaceholderView() {
placeholderLabel.textColor = .lightGray
placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body)
placeholderLabel.isHidden = !text.isEmpty
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(placeholderLabel)
NSLayoutConstraint.activate([
placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7),
placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8)
])
}
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged self.onFocusChanged = onFocusChanged
@ -214,7 +172,6 @@ class CustomUITextField: UITextView, UITextViewDelegate {
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
placeholderLabel.isHidden = !text.isEmpty
if textView.markedTextRange == nil { if textView.markedTextRange == nil {
var images: [UploadContent] = [] var images: [UploadContent] = []
var rangeDiff = 0 var rangeDiff = 0
@ -246,22 +203,10 @@ class CustomUITextField: UITextView, UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) { func textViewDidBeginEditing(_ textView: UITextView) {
onFocusChanged(true) onFocusChanged(true)
updateSelectedRange(textView)
} }
func textViewDidEndEditing(_ textView: UITextView) { func textViewDidEndEditing(_ textView: UITextView) {
onFocusChanged(false) onFocusChanged(false)
updateSelectedRange(textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
updateSelectedRange(textView)
}
private func updateSelectedRange(_ textView: UITextView) {
if parent?.selectedRange != textView.selectedRange {
parent?.selectedRange = textView.selectedRange
}
} }
} }
@ -272,9 +217,6 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false), disableEditing: Binding.constant(false),
height: Binding.constant(100), height: Binding.constant(100),
focused: Binding.constant(false), focused: Binding.constant(false),
lastUnfocusedDate: Binding.constant(.now),
placeholder: Binding.constant("Placeholder"),
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
onImagesAdded: { _ in } onImagesAdded: { _ in }
) )
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View file

@ -13,9 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View { struct SendMessageView: View {
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil
@ -33,9 +31,8 @@ struct SendMessageView: View {
@State private var holdingVMR = false @State private var holdingVMR = false
@Namespace var namespace @Namespace var namespace
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
var sendButtonColor = Color.accentColor var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body @State private var teFont: Font = .body
@State private var sendButtonSize: CGFloat = 29 @State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1 @State private var sendButtonOpacity: CGFloat = 1
@ -43,57 +40,55 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false @State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false @State private var progressByTimeout = false
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View { var body: some View {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) ZStack {
ZStack(alignment: .leading) { let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
if case .voicePreview = composeState.preview { HStack(alignment: .bottom) {
Text("Voice message…") ZStack(alignment: .leading) {
.font(teFont.italic()) if case .voicePreview = composeState.preview {
.multilineTextAlignment(.leading) Text("Voice message…")
.foregroundColor(theme.colors.secondary) .font(teFont.italic())
.padding(.horizontal, 10) .multilineTextAlignment(.leading)
.padding(.vertical, 8) .foregroundColor(theme.colors.secondary)
.padding(.trailing, 32) .padding(.horizontal, 10)
.frame(maxWidth: .infinity) .padding(.vertical, 8)
} else { .frame(maxWidth: .infinity)
NativeTextEditor( } else {
text: $composeState.message, NativeTextEditor(
disableEditing: $composeState.inProgress, text: $composeState.message,
height: $teHeight, disableEditing: $composeState.inProgress,
focused: $keyboardVisible, height: $teHeight,
lastUnfocusedDate: $keyboardHiddenDate, focused: $keyboardVisible,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), onImagesAdded: onMediaAdded
selectedRange: $selectedRange, )
onImagesAdded: onMediaAdded .allowsTightening(false)
) .fixedSize(horizontal: false, vertical: true)
.padding(.trailing, 32) }
.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.3, antialiased: true))
} }
.overlay(alignment: .topTrailing, content: {
if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
deleteTextButton()
}
})
.overlay(alignment: .bottomTrailing, content: {
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 4)
} else {
composeActionButtons()
// required for intercepting clicks
.background(.white.opacity(0.000001))
}
})
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in .onChange(of: composeState.inProgress) { inProgress in
if inProgress { if inProgress {
@ -111,8 +106,6 @@ struct SendMessageView: View {
let vmrs = composeState.voiceMessageRecordingState let vmrs = composeState.voiceMessageRecordingState
if nextSendGrpInv { if nextSendGrpInv {
inviteMemberContactButton() inviteMemberContactButton()
} else if case .reportedItem = composeState.contextItem {
sendMessageButton()
} else if showVoiceMessageButton } else if showVoiceMessageButton
&& composeState.message.isEmpty && composeState.message.isEmpty
&& !composeState.editing && !composeState.editing
@ -172,7 +165,7 @@ struct SendMessageView: View {
!composeState.sendEnabled || !composeState.sendEnabled ||
composeState.inProgress composeState.inProgress
) )
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
@ -195,7 +188,7 @@ struct SendMessageView: View {
composeState.endLiveDisabled || composeState.endLiveDisabled ||
disableSendButton disableSendButton
) )
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.contextMenu{ .contextMenu{
sendButtonContextMenuItems() sendButtonContextMenuItems()
} }
@ -235,7 +228,6 @@ struct SendMessageView: View {
!composeState.editing { !composeState.editing {
if case .noContextItem = composeState.contextItem, if case .noContextItem = composeState.contextItem,
!composeState.voicePreview, !composeState.voicePreview,
!composeState.manyMediaPreviews,
let send = sendLiveMessage, let send = sendLiveMessage,
let update = updateLiveMessage { let update = updateLiveMessage {
Button { Button {
@ -256,7 +248,6 @@ struct SendMessageView: View {
} }
private struct RecordVoiceMessageButton: View { private struct RecordVoiceMessageButton: View {
@Environment(\.isEnabled) var isEnabled
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)? var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)? var finishVoiceMessageRecording: (() -> Void)?
@ -265,14 +256,12 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil @State private var pressed: TimeInterval? = nil
var body: some View { var body: some View {
Image(systemName: isEnabled ? "mic.fill" : "mic") Button(action: {}) {
.resizable() Image(systemName: "mic.fill")
.scaledToFit() .foregroundColor(theme.colors.primary)
.frame(width: 20, height: 20) }
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.opacity(holdingVMR ? 0.7 : 1)
.disabled(disabled) .disabled(disabled)
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
._onButtonGesture { down in ._onButtonGesture { down in
if down { if down {
@ -280,7 +269,9 @@ struct SendMessageView: View {
pressed = ProcessInfo.processInfo.systemUptime pressed = ProcessInfo.processInfo.systemUptime
startVoiceMessageRecording?() startVoiceMessageRecording?()
} else { } else {
if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 { let now = ProcessInfo.processInfo.systemUptime
if let pressed = pressed,
now - pressed >= 1 {
finishVoiceMessageRecording?() finishVoiceMessageRecording?()
} }
holdingVMR = false holdingVMR = false
@ -320,13 +311,10 @@ struct SendMessageView: View {
} }
} label: { } label: {
Image(systemName: "mic") Image(systemName: "mic")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
@ -354,7 +342,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) .foregroundColor(theme.colors.primary)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@ -411,7 +399,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
@ -427,10 +415,8 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider { struct SendMessageView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var composeStateNew = ComposeState() @State var composeStateNew = ComposeState()
@State var selectedRange = NSRange()
let ci = ChatItem.getSample(1, .directSnd, .now, "hello") let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var composeStateEditing = ComposeState(editingItem: ci) @State var composeStateEditing = ComposeState(editingItem: ci)
@State var selectedRangeEditing = NSRange()
@State var sendEnabled: Bool = true @State var sendEnabled: Bool = true
return Group { return Group {
@ -439,11 +425,9 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0) Spacer(minLength: 0)
SendMessageView( SendMessageView(
composeState: $composeStateNew, composeState: $composeStateNew,
selectedRange: $selectedRange,
sendMessage: { _ in }, sendMessage: { _ in },
onMediaAdded: { _ in }, onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true)
keyboardHiddenDate: Binding.constant(Date.now)
) )
} }
VStack { VStack {
@ -451,11 +435,9 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0) Spacer(minLength: 0)
SendMessageView( SendMessageView(
composeState: $composeStateEditing, composeState: $composeStateEditing,
selectedRange: $selectedRangeEditing,
sendMessage: { _ in }, sendMessage: { _ in },
onMediaAdded: { _ in }, onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true)
keyboardHiddenDate: Binding.constant(Date.now)
) )
} }
} }

View file

@ -14,10 +14,9 @@ struct ContactPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var contact: Contact @Binding var contact: Contact
@Binding var featuresAllowed: ContactFeaturesAllowed @State var featuresAllowed: ContactFeaturesAllowed
@Binding var currentFeaturesAllowed: ContactFeaturesAllowed @State var currentFeaturesAllowed: ContactFeaturesAllowed
@State private var showSaveDialogue = false @State private var showSaveDialogue = false
let savePreferences: () -> Void
var body: some View { var body: some View {
let user: User = chatModel.currentUser! let user: User = chatModel.currentUser!
@ -49,10 +48,7 @@ struct ContactPreferencesView: View {
savePreferences() savePreferences()
dismiss() dismiss()
} }
Button("Exit without saving") { Button("Exit without saving") { dismiss() }
featuresAllowed = currentFeaturesAllowed
dismiss()
}
} }
} }
@ -122,15 +118,31 @@ struct ContactPreferencesView: View {
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View { private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
Text(feature.enabledDescription(enabled)) Text(feature.enabledDescription(enabled))
} }
private func savePreferences() {
Task {
do {
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
contact = toContact
chatModel.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
}
} catch {
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
}
}
}
} }
struct ContactPreferencesView_Previews: PreviewProvider { struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ContactPreferencesView( ContactPreferencesView(
contact: Binding.constant(Contact.sampleData), contact: Binding.constant(Contact.sampleData),
featuresAllowed: Binding.constant(ContactFeaturesAllowed.sampleData), featuresAllowed: ContactFeaturesAllowed.sampleData,
currentFeaturesAllowed: Binding.constant(ContactFeaturesAllowed.sampleData), currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
savePreferences: {}
) )
} }
} }

View file

@ -1,715 +0,0 @@
//
// EndlessScrollView.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 25.01.2025.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ScrollRepresentable<Content: View, ScrollItem>: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
let scrollView: EndlessScrollView<ScrollItem>
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<ScrollItem>
fileprivate var items: [ScrollItem] = []
fileprivate var content: ((Int, ScrollItem) -> Content)!
fileprivate init(scrollView: EndlessScrollView<ScrollItem>, 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<Content>() : cellsToReuse.removeLast() as! HostingCell<Content>
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<Content> {
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<ScrollItem>: 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<ScrollItem>.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<ScrollItem>.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<ScrollItem>.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<ScrollItem>.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)")
}

View file

@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable { private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@ -47,13 +47,14 @@ struct AddGroupMembersViewCommon: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
addGroupMembersView() NavigationView {
.navigationBarBackButtonHidden() addGroupMembersView()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb(selectedContacts) } Button ("Skip") { addedMembersCb(selectedContacts) }
}
} }
} }
} else { } else {
addGroupMembersView() addGroupMembersView()
} }
@ -78,12 +79,7 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count let count = selectedContacts.count
Section { Section {
if creatingGroup { if creatingGroup {
GroupPreferencesButton( groupPreferencesButton($groupInfo, true)
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
currentPreferences: groupInfo.fullGroupPreferences,
creatingGroup: true
)
} }
rolePicker() rolePicker()
inviteMembersButton() inviteMembersButton()
@ -110,10 +106,8 @@ struct AddGroupMembersViewCommon: View {
.padding(.leading, 2) .padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) } let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
ForEach(members + [dummyContact]) { contact in ForEach(members) { contact in
if contact.contactId != dummyContact.contactId { contactCheckView(contact)
contactCheckView(contact)
}
} }
} }
} }
@ -128,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: selectedContacts) { _ in .onChange(of: selectedContacts) { _ in
@ -137,21 +131,12 @@ struct AddGroupMembersViewCommon: View {
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }
// Resolves keyboard losing focus bug in iOS16 and iOS17,
// when there are no items inside `ForEach(memebers)` loop
private let dummyContact: Contact = {
var dummy = Contact.sampleData
dummy.contactId = -1
return dummy
}()
private func inviteMembersButton() -> some View { private func inviteMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat" Button {
return Button {
inviteMembers() inviteMembers()
} label: { } label: {
HStack { HStack {
Text(label) Text("Invite to group")
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
} }
@ -175,8 +160,10 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View { private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) { Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in ForEach(GroupMemberRole.allCases) { role in
Text(role.text) if role <= groupInfo.membership.memberRole && role != .author {
Text(role.text)
}
} }
} }
.frame(height: 36) .frame(height: 36)
@ -235,7 +222,6 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding,
.focused(focussed) .focused(focussed)
.foregroundColor(onBackgroundColor) .foregroundColor(onBackgroundColor)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.autocorrectionDisabled(true)
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()

View file

@ -17,19 +17,14 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
var onSearch: () -> Void
@State var localAlias: String
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: CreatedConnLink? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true @State private var sendReceiptsUserDefault = true
@State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
@ -45,7 +40,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@ -70,112 +65,85 @@ struct GroupChatInfoView: View {
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
ZStack { List {
List { groupInfoHeader()
groupInfoHeader() .listRowBackground(Color.clear)
.listRowBackground(Color.clear)
Section {
localAliasTextEdit() if groupInfo.canEdit {
.listRowBackground(Color.clear) editGroupButton()
.listRowSeparator(.hidden) }
.padding(.bottom, 18) if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
addOrEditWelcomeMessage()
infoActionButtons() }
.padding(.horizontal) groupPreferencesButton($groupInfo)
.frame(maxWidth: .infinity) if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
.frame(height: infoViewActionButtonHeight) sendReceiptsOption()
.listRowBackground(Color.clear) } else {
.listRowSeparator(.hidden) sendReceiptsOptionDisabled()
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) }
NavigationLink {
Section { ChatWallpaperEditorSheet(chat: chat)
if groupInfo.isOwner && groupInfo.businessChat == nil { } label: {
editGroupButton() Label("Chat theme", systemImage: "photo")
} }
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { } header: {
addOrEditWelcomeMessage() Text("")
} } footer: {
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) Text("Only group owners can change group preferences.")
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { .foregroundColor(theme.colors.secondary)
sendReceiptsOption() }
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
groupLinkButton()
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { alert = .cantInviteIncognitoAlert }
} else { } else {
sendReceiptsOptionDisabled() addMembersButton()
} }
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
} header: {
Text("")
} footer: {
let label: LocalizedStringKey = (
groupInfo.businessChat == nil
? "Only group owners can change group preferences."
: "Only chat owners can change preferences."
)
Text(label)
.foregroundColor(theme.colors.secondary)
} }
if members.count > 8 {
Section {
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
Text("Delete chat messages from your device.")
}
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
if groupInfo.businessChat == nil {
groupLinkButton()
}
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { alert = .cantInviteIncognitoAlert }
} else {
addMembersButton()
}
}
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8) .padding(.leading, 8)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == ""
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
ForEach(filteredMembers) { member in
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert)
}
} }
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
Section { let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
clearChatButton() MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
if groupInfo.canDelete { ForEach(filteredMembers) { member in
deleteGroupButton() ZStack {
} NavigationLink {
if groupInfo.membership.memberCurrent { memberInfoView(member)
leaveGroupButton() } label: {
} EmptyView()
} }
.opacity(0)
if developerTools { MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true) Section {
.disabled(progressIndicator) clearChatButton()
.opacity(progressIndicator ? 0.6 : 1) if groupInfo.canDelete {
deleteGroupButton()
if progressIndicator { }
ProgressView().scaleEffect(2) if groupInfo.membership.memberCurrent {
leaveGroupButton()
}
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
}
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
@ -190,7 +158,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@ -206,6 +174,7 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
} }
} }
.keyboardPadding()
} }
private func groupInfoHeader() -> some View { private func groupInfoHeader() -> some View {
@ -214,7 +183,7 @@ struct GroupChatInfoView: View {
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12) .padding(.top, 12)
.padding() .padding()
Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName) Text(cInfo.displayName)
.font(.largeTitle) .font(.largeTitle)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(4) .lineLimit(4)
@ -229,128 +198,25 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
private func localAliasTextEdit() -> some View { private func addMembersButton() -> some View {
TextField("Set chat name…", text: $localAlias) NavigationLink {
.disableAutocorrection(true) AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.focused($aliasTextFieldFocused) .onAppear {
.submitLabel(.done) searchFocussed = false
.onChange(of: aliasTextFieldFocused) { focused in Task {
if !focused { let groupMembers = await apiListMembers(groupInfo.groupId)
setGroupAlias() await MainActor.run {
} chatModel.groupMembers = groupMembers.map { GMember.init($0) }
} chatModel.populateGroupMembersIndexes()
.onSubmit { }
setGroupAlias()
}
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
private func setGroupAlias() {
Task {
do {
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
await MainActor.run {
chatModel.updateGroup(gInfo)
} }
} }
} catch {
logger.error("setGroupAlias error: \(responseError(error))")
}
}
}
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
if let nextNtfMode = chat.chatInfo.nextNtfMode {
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
private func addMembersActionButton(width: CGFloat) -> some View {
ZStack {
if chat.chatInfo.incognito {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
} else {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
NavigationLink(isActive: $addMembersNavLinkActive) {
addMembersDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
.disabled(!groupInfo.ready)
}
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
return InfoViewButton(
image: nextNtfMode.iconFilled,
title: "\(nextNtfMode.text(mentions: true))",
width: width
) {
toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View {
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
case .customer: "Add team members"
case .business: "Add friends"
case .none: "Invite members"
}
return NavigationLink {
addMembersDestinationView()
} label: { } label: {
Label(label, systemImage: "plus") Label("Invite members", systemImage: "plus")
} }
} }
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
await chatModel.loadGroupMembers(groupInfo)
}
}
}
private struct MemberRowView: View { private struct MemberRowView: View {
var chat: Chat
var groupInfo: GroupInfo var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember @ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ -359,8 +225,8 @@ struct GroupChatInfoView: View {
var body: some View { var body: some View {
let member = groupMember.wrapped let member = groupMember.wrapped
let v1 = HStack{ let v = HStack{
MemberProfileImage(member, size: 38) ProfileImage(imageStr: member.image, size: 38)
.padding(.trailing, 2) .padding(.trailing, 2)
// TODO server connection status // TODO server connection status
VStack(alignment: .leading) { VStack(alignment: .leading) {
@ -375,21 +241,7 @@ struct GroupChatInfoView: View {
Spacer() Spacer()
memberInfo(member) memberInfo(member)
} }
let v = ZStack {
if user {
v1
} else {
NavigationLink {
memberInfoView()
} label: {
EmptyView()
}
.opacity(0)
v1
}
}
if user { if user {
v v
} else if groupInfo.membership.memberRole >= .admin { } else if groupInfo.membership.memberRole >= .admin {
@ -414,11 +266,6 @@ struct GroupChatInfoView: View {
} }
} }
private func memberInfoView() -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false { if member.activeConn?.connDisabled ?? false {
return "disabled" return "disabled"
@ -490,7 +337,7 @@ struct GroupChatInfoView: View {
} }
private var memberVerifiedShield: Text { private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption) .font(.caption)
.baselineOffset(2) .baselineOffset(2)
.kerning(-2) .kerning(-2)
@ -498,9 +345,23 @@ struct GroupChatInfoView: View {
} }
} }
private func memberInfoView(_ groupMember: GMember) -> some View {
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
.navigationBarHidden(false)
}
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
groupLinkDestinationView() GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
if groupLink == nil { if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus") Label("Create group link", systemImage: "link.badge.plus")
@ -510,19 +371,6 @@ struct GroupChatInfoView: View {
} }
} }
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func editGroupButton() -> some View { private func editGroupButton() -> some View {
NavigationLink { NavigationLink {
GroupProfileView( GroupProfileView(
@ -554,12 +402,11 @@ struct GroupChatInfoView: View {
} }
} }
@ViewBuilder private func deleteGroupButton() -> some View { private func deleteGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
Button(role: .destructive) { Button(role: .destructive) {
alert = .deleteGroupAlert alert = .deleteGroupAlert
} label: { } label: {
Label(label, systemImage: "trash") Label("Delete group", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(Color.red)
} }
} }
@ -574,21 +421,19 @@ struct GroupChatInfoView: View {
} }
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 alert = .leaveGroupAlert
} label: { } label: {
Label(label, systemImage: "rectangle.portrait.and.arrow.right") Label("Leave group", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(Color.red) .foregroundColor(Color.red)
} }
} }
// TODO reuse this and clearChatAlert with ChatInfoView // TODO reuse this and clearChatAlert with ChatInfoView
private func deleteGroupAlert() -> Alert { private func deleteGroupAlert() -> Alert {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert( return Alert(
title: Text(label), title: Text("Delete group?"),
message: deleteGroupAlertMessage(groupInfo), message: deleteGroupAlertMessage(),
primaryButton: .destructive(Text("Delete")) { primaryButton: .destructive(Text("Delete")) {
Task { Task {
do { do {
@ -607,6 +452,10 @@ struct GroupChatInfoView: View {
) )
} }
private func deleteGroupAlertMessage() -> Text {
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
}
private func clearChatAlert() -> Alert { private func clearChatAlert() -> Alert {
Alert( Alert(
title: Text("Clear conversation?"), title: Text("Clear conversation?"),
@ -622,15 +471,9 @@ struct GroupChatInfoView: View {
} }
private func leaveGroupAlert() -> Alert { private func leaveGroupAlert() -> Alert {
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" Alert(
let messageLabel: LocalizedStringKey = ( title: Text("Leave group?"),
groupInfo.businessChat == nil message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
? "You will stop receiving messages from this group. Chat history will be preserved."
: "You will stop receiving messages from this chat. Chat history will be preserved."
)
return Alert(
title: Text(titleLabel),
message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) { primaryButton: .destructive(Text("Leave")) {
Task { Task {
await leaveGroup(chat.chatInfo.apiId) await leaveGroup(chat.chatInfo.apiId)
@ -674,25 +517,18 @@ struct GroupChatInfoView: View {
} }
private func removeMemberAlert(_ mem: GroupMember) -> Alert { private func removeMemberAlert(_ mem: GroupMember) -> Alert {
let messageLabel: LocalizedStringKey = ( Alert(
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?"), title: Text("Remove member?"),
message: Text(messageLabel), message: Text("Member will be removed from group - this cannot be undone!"),
primaryButton: .destructive(Text("Remove")) { primaryButton: .destructive(Text("Remove")) {
Task { Task {
do { do {
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
await MainActor.run { await MainActor.run {
updatedMembers.forEach { updatedMember in _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
} }
} catch let error { } catch let error {
logger.error("apiRemoveMembers error: \(responseError(error))") logger.error("apiRemoveMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member") let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message) alert = .error(title: a.title, error: a.message)
} }
@ -703,80 +539,26 @@ struct GroupChatInfoView: View {
} }
} }
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
groupInfo.businessChat == nil ? ( NavigationLink {
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") GroupPreferencesView(
) : ( groupInfo: groupInfo,
groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!") preferences: groupInfo.wrappedValue.fullGroupPreferences,
) currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
} creatingGroup: creatingGroup
)
struct GroupPreferencesButton: View { .navigationBarTitle("Group preferences")
@Binding var groupInfo: GroupInfo .modifier(ThemedBackground(grouped: true))
@State var preferences: FullGroupPreferences .navigationBarTitleDisplayMode(.large)
@State var currentPreferences: FullGroupPreferences } label: {
var creatingGroup: Bool = false if creatingGroup {
Text("Set group preferences")
private var label: LocalizedStringKey { } else {
groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences" Label("Group preferences", systemImage: "switch.2")
}
var body: some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: $preferences,
currentPreferences: currentPreferences,
creatingGroup: creatingGroup,
savePreferences: savePreferences
)
.navigationBarTitle(label)
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
.onDisappear {
let saveText = NSLocalizedString(
creatingGroup ? "Save" : "Save and notify group members",
comment: "alert button"
)
if groupInfo.fullGroupPreferences != preferences {
showAlert(
title: NSLocalizedString("Save preferences?", comment: "alert title"),
buttonTitle: saveText,
buttonAction: { savePreferences() },
cancelButton: true
)
}
}
} label: {
if creatingGroup {
Text("Set group preferences")
} else {
Label(label, systemImage: "switch.2")
}
} }
} }
private func savePreferences() {
Task {
do {
var gp = groupInfo.groupProfile
gp.groupPreferences = toGroupPreferences(preferences)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
currentPreferences = preferences
}
} catch {
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
}
}
}
} }
func cantInviteIncognitoAlert() -> Alert { func cantInviteIncognitoAlert() -> Alert {
Alert( Alert(
title: Text("Can't invite contacts!"), title: Text("Can't invite contacts!"),
@ -795,9 +577,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupChatInfoView( GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData), groupInfo: Binding.constant(GroupInfo.sampleData)
onSearch: {},
localAlias: ""
) )
} }
} }

View file

@ -10,21 +10,19 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct GroupLinkView: View { struct GroupLinkView: View {
@EnvironmentObject var theme: AppTheme
var groupId: Int64 var groupId: Int64
@Binding var groupLink: CreatedConnLink? @Binding var groupLink: String?
@Binding var groupLinkMemberRole: GroupMemberRole @Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false var showTitle: Bool = false
var creatingGroup: Bool = false var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil var linkCreatedCb: (() -> Void)? = nil
@State private var showShortLink = true
@State private var creatingLink = false @State private var creatingLink = false
@State private var alert: GroupLinkAlert? @State private var alert: GroupLinkAlert?
@State private var shouldCreate = true @State private var shouldCreate = true
private enum GroupLinkAlert: Identifiable { private enum GroupLinkAlert: Identifiable {
case deleteLink case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@ -36,13 +34,14 @@ struct GroupLinkView: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
groupLinkView() NavigationView {
.navigationBarBackButtonHidden() groupLinkView()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() } Button ("Continue") { linkCreatedCb?() }
}
} }
} }
} else { } else {
groupLinkView() groupLinkView()
} }
@ -71,10 +70,10 @@ struct GroupLinkView: View {
} }
} }
.frame(height: 36) .frame(height: 36)
SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink) SimpleXLinkQRCode(uri: groupLink)
.id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))") .id("simplex-qrcode-view-for-\(groupLink)")
Button { Button {
showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)]) showShareSheet(items: [simplexChatLink(groupLink)])
} label: { } label: {
Label("Share link", systemImage: "square.and.arrow.up") Label("Share link", systemImage: "square.and.arrow.up")
} }
@ -95,10 +94,6 @@ struct GroupLinkView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
} header: {
if let groupLink, groupLink.connShortLink != nil {
ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink)
}
} }
.alert(item: $alert) { alert in .alert(item: $alert) { alert in
switch alert { switch alert {
@ -118,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel() }, secondaryButton: .cancel()
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: groupLinkMemberRole) { _ in .onChange(of: groupLinkMemberRole) { _ in
@ -164,8 +159,8 @@ struct GroupLinkView: View {
struct GroupLinkView_Previews: PreviewProvider { struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil) @State var 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: CreatedConnLink? = nil @State var noGroupLink: String? = nil
return Group { return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))

View file

@ -14,15 +14,10 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo @State var groupInfo: GroupInfo
@ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember @ObservedObject var groupMember: GMember
var navigation: Bool = false var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil @State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil @State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@State private var knownContactChat: Chat? = nil
@State private var knownContact: Contact? = nil
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member @State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert? @State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet? @State private var sheet: PlanAndConnectActionSheet?
@ -42,8 +37,7 @@ struct GroupMemberInfoView: View {
case syncConnectionForceAlert case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert) case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String) case queueInfo(info: String)
case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@ -58,7 +52,6 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .queueInfo(info): return "queueInfo \(info)" case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@ -72,11 +65,10 @@ struct GroupMemberInfoView: View {
} }
} }
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? { private func knownDirectChat(_ contactId: Int64) -> Chat? {
if let chat = chatModel.getContactChat(contactId), if let chat = chatModel.getContactChat(contactId),
let contact = chat.chatInfo.contact, chat.chatInfo.contact?.directOrUsed == true {
contact.directOrUsed == true { return chat
return (chat, contact)
} else { } else {
return nil return nil
} }
@ -88,160 +80,138 @@ struct GroupMemberInfoView: View {
List { List {
groupMemberInfoHeader(member) groupMemberInfoHeader(member)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
infoActionButtons(member) if member.memberActive {
.padding(.horizontal) Section {
.frame(maxWidth: .infinity) if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
.frame(height: infoViewActionButtonHeight) knownDirectChatButton(chat)
.listRowBackground(Color.clear) } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
.listRowSeparator(.hidden) if let contactId = member.memberContactId {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
if connectionLoaded { createMemberContactButton()
if member.memberActive {
Section {
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
} }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
}
if let contactLink = member.contactLink { if let contactLink = member.contactLink {
Section { Section {
SimpleXLinkQRCode(uri: contactLink) SimpleXLinkQRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [simplexChatLink(contactLink)]) showShareSheet(items: [simplexChatLink(contactLink)])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
if member.memberContactId != nil { if let contactId = member.memberContactId {
if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink) connectViaAddressButton(contactLink)
} }
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
infoRow(label, groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else { } else {
infoRow("Role", member.memberRole.text) connectViaAddressButton(contactLink)
} }
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
} }
}
if let connStats = connectionStats { Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { infoRow("Group", groupInfo.displayName)
// TODO network connection status
Button("Change receiving address") { if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
alert = .switchAddressAlert Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
} }
.disabled( .disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| !member.sendMsgEnabled || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| !member.sendMsgEnabled
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
} }
}
if groupInfo.membership.memberRole >= .admin { if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member) adminDestructiveSection(member)
} else { } else {
nonAdminBlockSection(member) nonAdminBlockSection(member)
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName) infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)") infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn { if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc) infoRow("Connection", connLevelDesc)
} }
Button ("Debug delivery") { Button ("Debug delivery") {
Task { Task {
do { do {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId)) let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
await MainActor.run { alert = .queueInfo(info: info) } await MainActor.run { alert = .queueInfo(info: info) }
} catch let e { } catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))") logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error") let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) } await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
} }
} }
} }
} }
} }
} }
.navigationBarHidden(true) .navigationBarHidden(true)
.task { .onAppear {
if #unavailable(iOS 16) { if #unavailable(iOS 16) {
// this condition prevents re-setting picker // this condition prevents re-setting picker
if !justOpened { return } if !justOpened { return }
} }
justOpened = false justOpened = false
newRole = member.memberRole DispatchQueue.main.async {
do { newRole = member.memberRole
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) do {
let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
await MainActor.run { let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
_ = chatModel.upsertGroupMember(groupInfo, mem) _ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats connectionStats = stats
connectionCode = code connectionCode = code
connectionLoaded = true
}
} catch let error {
await MainActor.run {
connectionLoaded = true
}
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
knownContactChat = contactChat
knownContact = contact
do {
let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
await MainActor.run {
knownContactConnectionStats = stats
}
} catch let error { } catch let error {
logger.error("apiContactInfo error: \(responseError(error))") logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
} }
} }
} }
@ -267,8 +237,7 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info) case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
@ -277,70 +246,9 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
} }
} }
.onChange(of: chat.chatInfo) { c in
if case let .group(gI) = chat.chatInfo {
groupInfo = gI
}
}
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }
func infoActionButtons(_ member: GroupMember) -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let chat = knownContactChat, let contact = knownContact {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
createMemberContactButton(member, width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
} else { // no known contact chat && directMessages are off
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
func showSendMessageToEnableCallsAlert() {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't call member",
message: "Send message to enable calls."
),
id: "can't call member, send message"
))
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
let messageLabel: LocalizedStringKey = (
groupInfo.businessChat == nil
? "Direct messages between members are prohibited."
: "Direct messages between members are prohibited in this chat."
)
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
message: messageLabel
),
id: "can't message member, direct messages prohibited"
))
}
func connectViaAddressButton(_ contactLink: String) -> some View { func connectViaAddressButton(_ contactLink: String) -> some View {
Button { Button {
planAndConnect( planAndConnect(
@ -355,94 +263,64 @@ struct GroupMemberInfoView: View {
} }
} }
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View { func knownDirectChatButton(_ chat: Chat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
ItemsModel.shared.loadOpenChat(chat.id) { dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} DispatchQueue.main.async {
} chatModel.chatId = chat.id
}
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
ItemsModel.shared.loadOpenChat("@\(contactId)") {
dismissAllSheets(animated: true)
} }
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
func createMemberContactButton(_ member: GroupMember, width: CGFloat) -> some View { func createMemberContactButton() -> some View {
InfoViewButton( Button {
image: "message.fill", progressIndicator = true
title: "message", Task {
disabledLook: do {
!( let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
member.sendMsgEnabled || await MainActor.run {
(member.activeConn?.connectionStats?.ratchetSyncAllowed ?? false) progressIndicator = false
), chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
width: width dismissAllSheets(animated: true)
) { chatModel.chatId = memberContact.id
if member.sendMsgEnabled { chatModel.setContactNetworkStatus(memberContact, .connected)
progressIndicator = true }
Task { } catch let error {
do { logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run { await MainActor.run {
progressIndicator = false progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) alert = .error(title: a.title, error: a.message)
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
} }
} }
} else if let connStats = connectionStats {
if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
syncMemberConnection(force: false)
},
secondaryButton: .cancel()
),
id: "can't message member, fix connection"
))
} else if connStats.ratchetSyncInProgress {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Encryption renegotiation in progress."
),
id: "can't message member, encryption renegotiation in progress"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Connection not ready."
),
id: "can't message member, connection not ready"
))
}
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View { private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack { VStack {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill)) ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12) .padding(.top, 12)
.padding() .padding()
if mem.verified { if mem.verified {
@ -450,7 +328,7 @@ struct GroupMemberInfoView: View {
Text(Image(systemName: "checkmark.shield")) Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.font(.title2) .font(.title2)
+ textSpace + Text(" ")
+ Text(mem.displayName) + Text(mem.displayName)
.font(.largeTitle) .font(.largeTitle)
) )
@ -599,26 +477,19 @@ struct GroupMemberInfoView: View {
} }
private func removeMemberAlert(_ mem: GroupMember) -> Alert { private func removeMemberAlert(_ mem: GroupMember) -> Alert {
let label: LocalizedStringKey = ( Alert(
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?"), title: Text("Remove member?"),
message: Text(label), message: Text("Member will be removed from group - this cannot be undone!"),
primaryButton: .destructive(Text("Remove")) { primaryButton: .destructive(Text("Remove")) {
Task { Task {
do { do {
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
await MainActor.run { await MainActor.run {
updatedMembers.forEach { updatedMember in _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
dismiss() dismiss()
} }
} catch let error { } catch let error {
logger.error("apiRemoveMembers error: \(responseError(error))") logger.error("apiRemoveMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member") let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message) alert = .error(title: a.title, error: a.message)
} }
@ -631,28 +502,18 @@ struct GroupMemberInfoView: View {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert { private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert( Alert(
title: Text("Change member role?"), title: Text("Change member role?"),
message: ( message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
mem.memberCurrent
? (
groupInfo.businessChat == nil
? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
: Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
)
: Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
),
primaryButton: .default(Text("Change")) { primaryButton: .default(Text("Change")) {
Task { Task {
do { do {
let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole) let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
await MainActor.run { await MainActor.run {
updatedMembers.forEach { updatedMember in _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
} }
} catch let error { } catch let error {
newRole = mem.memberRole newRole = mem.memberRole
logger.error("apiMembersRole error: \(responseError(error))") logger.error("apiMemberRole error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing role") let a = getErrorAlert(error, "Error changing role")
alert = .error(title: a.title, error: a.message) alert = .error(title: a.title, error: a.message)
} }
@ -721,21 +582,6 @@ struct GroupMemberInfoView: View {
} }
} }
func MemberProfileImage(
_ mem: GroupMember,
size: CGFloat,
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
backgroundColor: Color? = nil
) -> some View {
ProfileImage(
imageStr: mem.image,
size: size,
color: color,
backgroundColor: backgroundColor,
blurred: mem.blocked
)
}
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert( Alert(
title: Text("Block member?"), title: Text("Block member?"),
@ -804,14 +650,12 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
Task { Task {
do { do {
let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked) let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
await MainActor.run { await MainActor.run {
updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
}
} }
} catch let error { } catch let error {
logger.error("apiBlockMembersForAll error: \(responseError(error))") logger.error("apiBlockMemberForAll error: \(responseError(error))")
} }
} }
} }
@ -820,7 +664,6 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupMemberInfoView( GroupMemberInfoView(
groupInfo: GroupInfo.sampleData, groupInfo: GroupInfo.sampleData,
chat: Chat.sampleData,
groupMember: GMember.sampleData groupMember: GMember.sampleData
) )
} }

View file

@ -1,249 +0,0 @@
//
// GroupMentions.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 30/01/2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
let MENTION_START: Character = "@"
let QUOTE: Character = "'"
let MEMBER_ROW_SIZE: CGFloat = 60
let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
struct GroupMentionsView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@Binding var keyboardVisible: Bool
@State private var isVisible = false
@State private var currentMessage: String = ""
@State private var mentionName: String = ""
@State private var mentionRange: NSRange?
@State private var mentionMemberId: String?
@State private var sortedMembers: [GMember] = []
var body: some View {
ZStack(alignment: .bottom) {
if isVisible {
let filtered = filteredMembers()
if filtered.count > 0 {
Color.white.opacity(0.01)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isVisible = false
}
VStack(spacing: 0) {
Spacer()
Divider()
let scroll = ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
ZStack(alignment: .bottom) {
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
.disabled(disabled)
.opacity(disabled ? 0.6 : 1)
.onTapGesture {
memberSelected(member)
}
.padding(.horizontal)
.frame(height: MEMBER_ROW_SIZE)
Divider()
.padding(.leading)
.padding(.leading, 48)
}
}
}
}
.frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
.background(Color(UIColor.systemBackground))
if #available(iOS 16.0, *) {
scroll.scrollDismissesKeyboard(.never)
} else {
scroll
}
}
}
}
}
.onChange(of: composeState.parsedMessage) { parsedMsg in
currentMessage = composeState.message
messageChanged(currentMessage, parsedMsg, selectedRange)
}
.onChange(of: selectedRange) { r in
// This condition is needed to prevent messageChanged called twice,
// because composeState.formattedText triggers later when message changes.
// The condition is only true if position changed without text change
if currentMessage == composeState.message {
messageChanged(currentMessage, composeState.parsedMessage, r)
}
}
.onAppear {
currentMessage = composeState.message
}
}
private func filteredMembers() -> [GMember] {
let s = mentionName.lowercased()
return s.isEmpty
? sortedMembers
: sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
removeUnusedMentions(parsedMsg)
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
switch ft.format {
case let .mention(name):
isVisible = true
mentionName = name
mentionRange = r
mentionMemberId = composeState.mentions[name]?.memberId
if !m.membersLoaded {
Task {
await m.loadGroupMembers(groupInfo)
sortMembers()
}
}
return
case .none: () //
let pos = range.location
if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" {
let prevChar = getCharacter(msg, pos - 2)?.char
if prevChar == nil || prevChar == " " || prevChar == "\n" {
isVisible = true
mentionName = ""
mentionRange = atRange
mentionMemberId = nil
Task {
await m.loadGroupMembers(groupInfo)
sortMembers()
}
return
}
}
default: ()
}
}
closeMemberList()
}
private func sortMembers() {
sortedMembers = m.groupMembers.filter({ m in
let status = m.wrapped.memberStatus
return status != .memLeft && status != .memRemoved && status != .memInvited
})
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
}
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
let usedMentions: Set<String> = 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)
}
}
}

View file

@ -20,10 +20,9 @@ struct GroupPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@Binding var preferences: FullGroupPreferences @State var preferences: FullGroupPreferences
var currentPreferences: FullGroupPreferences @State var currentPreferences: FullGroupPreferences
let creatingGroup: Bool let creatingGroup: Bool
let savePreferences: () -> Void
@State private var showSaveDialogue = false @State private var showSaveDialogue = false
var body: some View { var body: some View {
@ -37,10 +36,9 @@ struct GroupPreferencesView: View {
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.files, $preferences.files.enable, $preferences.files.role)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.reports, $preferences.reports.enable)
featureSection(.history, $preferences.history.enable) featureSection(.history, $preferences.history.enable)
if groupInfo.isOwner { if groupInfo.canEdit {
Section { Section {
Button("Reset") { preferences = currentPreferences } Button("Reset") { preferences = currentPreferences }
Button(saveText) { savePreferences() } Button(saveText) { savePreferences() }
@ -70,10 +68,7 @@ struct GroupPreferencesView: View {
savePreferences() savePreferences()
dismiss() dismiss()
} }
Button("Exit without saving") { Button("Exit without saving") { dismiss() }
preferences = currentPreferences
dismiss()
}
} }
} }
@ -82,7 +77,7 @@ struct GroupPreferencesView: View {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
if groupInfo.isOwner { if groupInfo.canEdit {
let enable = Binding( let enable = Binding(
get: { enableFeature.wrappedValue == .on }, get: { enableFeature.wrappedValue == .on },
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off } set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
@ -90,7 +85,6 @@ struct GroupPreferencesView: View {
settingsRow(icon, color: color) { settingsRow(icon, color: color) {
Toggle(feature.text, isOn: enable) Toggle(feature.text, isOn: enable)
} }
.disabled(feature == .reports) // remove in 6.4
if timedOn { if timedOn {
DropdownCustomTimePicker( DropdownCustomTimePicker(
selection: $preferences.timedMessages.ttl, selection: $preferences.timedMessages.ttl,
@ -129,7 +123,7 @@ struct GroupPreferencesView: View {
} }
} }
} footer: { } footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner)) Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
.onChange(of: enableFeature.wrappedValue) { enabled in .onChange(of: enableFeature.wrappedValue) { enabled in
@ -138,16 +132,32 @@ struct GroupPreferencesView: View {
} }
} }
} }
private func savePreferences() {
Task {
do {
var gp = groupInfo.groupProfile
gp.groupPreferences = toGroupPreferences(preferences)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo)
currentPreferences = preferences
}
} catch {
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
}
}
}
} }
struct GroupPreferencesView_Previews: PreviewProvider { struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupPreferencesView( GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData), groupInfo: Binding.constant(GroupInfo.sampleData),
preferences: Binding.constant(FullGroupPreferences.sampleData), preferences: FullGroupPreferences.sampleData,
currentPreferences: FullGroupPreferences.sampleData, currentPreferences: FullGroupPreferences.sampleData,
creatingGroup: false, creatingGroup: false
savePreferences: {}
) )
} }
} }

View file

@ -110,13 +110,10 @@ struct GroupProfileView: View {
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
Task { if let image = image {
let resized: String? = if let image { groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) } else {
} else { groupProfile.image = nil
nil
}
await MainActor.run { groupProfile.image = resized }
} }
} }
.onAppear { .onAppear {

View file

@ -18,13 +18,12 @@ struct GroupWelcomeView: View {
@State private var editMode = true @State private var editMode = true
@FocusState private var keyboardVisible: Bool @FocusState private var keyboardVisible: Bool
@State private var showSaveDialog = false @State private var showSaveDialog = false
@State private var showSecrets: Set<Int> = []
let maxByteCount = 1200 let maxByteCount = 1200
var body: some View { var body: some View {
VStack { VStack {
if groupInfo.isOwner && groupInfo.businessChat == nil { if groupInfo.canEdit {
editorView() editorView()
.modifier(BackButton(disabled: Binding.constant(false)) { .modifier(BackButton(disabled: Binding.constant(false)) {
if welcomeTextUnchanged() { if welcomeTextUnchanged() {
@ -59,8 +58,7 @@ struct GroupWelcomeView: View {
} }
private func textPreview() -> some View { private func textPreview() -> some View {
let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background)) messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
.frame(minHeight: 130, alignment: .topLeading) .frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }

View file

@ -0,0 +1,273 @@
//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
let items: Array<Item>
@Binding var scrollState: ReverseListScrollModel<Item>.State
/// Closure, that returns user interface for a given item
let content: (Item) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: .zero, position: .top)
}
} else {
controller.update(items: items)
}
}
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var itemCount: Int = .zero
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
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<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, .zero)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.representer.content(item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
cell.backgroundColor = .clear
return cell
}
// 4. External state changes will require manual layout updates
NotificationCenter.default
.addObserver(
self,
selector: #selector(updateLayout),
name: notificationName,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
/// 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) {
if let index {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
tableView.scrollToRow(
at: IndexPath(row: index, section: .zero),
at: position,
animated: animated
)
Task { representer.scrollState = .atDestination }
}
}
func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
)
itemCount = items.count
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<Hosted: View>: UITableViewCell {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !contentView.subviews.contains(hostingController.view) {
contentView.addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: contentView.leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: contentView.trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: contentView.topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: contentView.bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
override func prepareForReuse() {
super.prepareForReuse()
hostingController.rootView = nil
}
}
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(Item.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: Item.ID) {
state = .scrollingTo(.item(id))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}

View file

@ -1,52 +0,0 @@
//
// 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<Hosted: View>: UIView, ReusableView {
private let hostingController = UIHostingController<Hosted?>(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
}
}

View file

@ -1,153 +0,0 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var archiveItems: () -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
var forwardItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canArchiveReports: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var forwardEnabled: Bool = false
@State var deleteCountProhibited = false
@State var forwardCountProhibited = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
if canArchiveReports {
archiveItems()
} else {
deleteItems(deleteForEveryoneEnabled)
}
} label: {
Image(systemName: "trash")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || deleteCountProhibited)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || deleteCountProhibited)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
forwardItems()
} label: {
Image(systemName: "arrowshape.turn.up.forward")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
}
.disabled(!forwardEnabled || forwardCountProhibited)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
deleteCountProhibited = count == 0 || count > 200
forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo {
info
} else {
nil
}
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport
ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, ar, me, onlyOwnGroupItems, fe, sel)
} else {
return r
}
}
moderateEnabled = me && !onlyOwnGroupItems
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}

View file

@ -10,7 +10,8 @@ import SwiftUI
struct ChatHelp: View { struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
let dismissSettingsSheet: DismissAction @Binding var showSettings: Bool
@State private var newChatMenuOption: NewChatMenuOption? = nil
var body: some View { var body: some View {
ScrollView { chatHelp() } ScrollView { chatHelp() }
@ -23,7 +24,7 @@ struct ChatHelp: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("To ask any questions and to receive updates:") Text("To ask any questions and to receive updates:")
Button("connect to SimpleX Chat developers.") { Button("connect to SimpleX Chat developers.") {
dismissSettingsSheet() showSettings = false
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL) UIApplication.shared.open(simplexTeamURL)
} }
@ -38,12 +39,11 @@ struct ChatHelp: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Tap button ") Text("Tap button ")
NewChatMenuButton() NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
Text("above, then choose:") Text("above, then choose:")
} }
Text("**Create 1-time link**: to create and share a new invitation link.") Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
Text("**Scan / Paste link**: to connect via a link you received.")
Text("**Create group**: to create a new group.") Text("**Create group**: to create a new group.")
} }
.padding(.top, 24) .padding(.top, 24)
@ -62,9 +62,8 @@ struct ChatHelp: View {
} }
struct ChatHelp_Previews: PreviewProvider { struct ChatHelp_Previews: PreviewProvider {
@Environment(\.dismiss) static var mockDismiss
static var previews: some View { static var previews: some View {
ChatHelp(dismissSettingsSheet: mockDismiss) @State var showSettings = false
return ChatHelp(showSettings: $showSettings)
} }
} }

View file

@ -9,58 +9,35 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
typealias DynamicSizes = ( private let rowHeights: [DynamicTypeSize: CGFloat] = [
rowHeight: CGFloat, .xSmall: 68,
profileImageSize: CGFloat, .small: 72,
mediaSize: CGFloat, .medium: 76,
incognitoSize: CGFloat, .large: 80,
chatInfoSize: CGFloat, .xLarge: 88,
unreadCorner: CGFloat, .xxLarge: 94,
unreadPadding: CGFloat .xxxLarge: 104,
) .accessibility1: 90,
.accessibility2: 100,
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [ .accessibility3: 120,
.xSmall: (68, 55, 33, 22, 18, 9, 3), .accessibility4: 130,
.small: (72, 57, 34, 22, 18, 9, 3), .accessibility5: 140
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
] ]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
struct ChatListNavLink: View { struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var showContactRequestDialog = false @State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false @State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false @State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false @State private var showInvalidJSON = false
@State private var alert: SomeAlert? = nil @State private var showDeleteContactActionSheet = false
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false @State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false @State private var inProgress = false
@State private var progressByTimeout = false @State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
var body: some View { var body: some View {
Group { Group {
switch chat.chatInfo { switch chat.chatInfo {
@ -87,26 +64,18 @@ struct ChatListNavLink: View {
progressByTimeout = false progressByTimeout = false
} }
} }
.actionSheet(item: $actionSheet) { $0.actionSheet }
} }
private func contactNavLink(_ contact: Contact) -> some View { @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group { Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
deleteContactDialog( showDeleteContactActionSheet = true
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
@ -117,44 +86,51 @@ struct ChatListNavLink: View {
} }
} else { } else {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
) )
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
tagChatButton(chat)
if !chat.chatItems.isEmpty { if !chat.chatItems.isEmpty {
clearChatButton() clearChatButton()
} }
Button { Button {
deleteContactDialog( if contact.ready || !contact.active {
chat, showDeleteContactActionSheet = true
contact, } else {
dismissToChatList: false, AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
showAlert: { alert = $0 }, }
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
.frame(height: rowHeights[dynamicTypeSize])
} }
} }
.alert(item: $alert) { $0.alert } .actionSheet(isPresented: $showDeleteContactActionSheet) {
.sheet(item: $sheet) { if contact.ready && contact.active {
if #available(iOS 16.0, *) { return ActionSheet(
$0.content title: Text("Delete contact?\nThis cannot be undone!"),
.presentationDetents([.fraction($0.fraction)]) buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else { } else {
$0.content return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
} }
} }
} }
@ -163,7 +139,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
if groupInfo.canDelete { if groupInfo.canDelete {
@ -183,12 +159,11 @@ struct ChatListNavLink: View {
.disabled(inProgress) .disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
} }
.swipeActions(edge: .trailing) { .swipeActions(edge: .trailing) {
tagChatButton(chat)
if (groupInfo.membership.memberCurrent) { if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo) leaveGroupChatButton(groupInfo)
} }
@ -198,59 +173,39 @@ struct ChatListNavLink: View {
} }
default: default:
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
tagChatButton(chat) if !chat.chatItems.isEmpty {
let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator
let showClearButton = !chat.chatItems.isEmpty
let showDeleteGroup = groupInfo.canDelete
let showLeaveGroup = groupInfo.membership.memberCurrent
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton && totalNumberOfButtons <= 3 {
clearChatButton() clearChatButton()
} }
if (groupInfo.membership.memberCurrent) {
if showReportsButton && totalNumberOfButtons <= 3 {
archiveAllReportsButton()
}
if showLeaveGroup {
leaveGroupChatButton(groupInfo) leaveGroupChatButton(groupInfo)
} }
if groupInfo.canDelete {
if showDeleteGroup && totalNumberOfButtons <= 3 {
deleteGroupChatButton(groupInfo) deleteGroupChatButton(groupInfo)
} else if totalNumberOfButtons > 3 {
if showDeleteGroup && !groupInfo.membership.memberActive {
deleteGroupChatButton(groupInfo)
moreOptionsButton(false, chat, groupInfo)
} else {
moreOptionsButton(true, chat, groupInfo)
}
} }
} }
} }
} }
private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready disabled: !noteFolder.ready
) )
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
} }
@ -268,7 +223,7 @@ struct ChatListNavLink: View {
await MainActor.run { inProgress = false } await MainActor.run { inProgress = false }
} }
} label: { } label: {
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI) Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
} }
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
} }
@ -278,14 +233,14 @@ struct ChatListNavLink: View {
Button { Button {
Task { await markChatRead(chat) } Task { await markChatRead(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) Label("Read", systemImage: "checkmark")
} }
.tint(theme.colors.primary) .tint(theme.colors.primary)
} else { } else {
Button { Button {
Task { await markChatUnread(chat) } Task { await markChatUnread(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI) Label("Unread", systemImage: "circlebadge.fill")
} }
.tint(theme.colors.primary) .tint(theme.colors.primary)
} }
@ -297,118 +252,33 @@ struct ChatListNavLink: View {
Button { Button {
toggleChatFavorite(chat, favorite: false) toggleChatFavorite(chat, favorite: false)
} label: { } label: {
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI) Label("Unfav.", systemImage: "star.slash")
} }
.tint(.green) .tint(.green)
} else { } else {
Button { Button {
toggleChatFavorite(chat, favorite: true) toggleChatFavorite(chat, favorite: true)
} label: { } label: {
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI) Label("Favorite", systemImage: "star.fill")
} }
.tint(.green) .tint(.green)
} }
} }
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
if let nextMode = chat.chatInfo.nextNtfMode {
Button {
toggleNotifications(chat, enableNtfs: nextMode)
} label: {
SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
}
} else {
EmptyView()
}
}
private func archiveAllReportsButton() -> some View {
Button {
AlertManager.shared.showAlert(archiveAllReportsAlert())
} label: {
SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI)
}
}
private func clearChatButton() -> some View { private func clearChatButton() -> some View {
Button { Button {
AlertManager.shared.showAlert(clearChatAlert()) AlertManager.shared.showAlert(clearChatAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
private func tagChatButton(_ chat: Chat) -> some View {
Button {
setTagChatSheet(chat)
} label: {
SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI)
}
.tint(.mint)
}
private func setTagChatSheet(_ chat: Chat) {
let screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
if chatTagsModel.userTags.isEmpty {
TagListEditor(chat: chat)
} else {
TagListView(chat: chat)
}
}
)
},
id: "lists sheet",
fraction: fraction
)
}
private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
Button {
var buttons: [Alert.Button] = []
buttons.append(.default(Text("Clear")) {
AlertManager.shared.showAlert(clearChatAlert())
})
if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready {
buttons.append(.default(Text("Archive reports")) {
AlertManager.shared.showAlert(archiveAllReportsAlert())
})
}
if canShowGroupDelete, let gi = groupInfo, gi.canDelete {
buttons.append(.destructive(Text("Delete")) {
AlertManager.shared.showAlert(deleteGroupAlert(gi))
})
}
buttons.append(.cancel())
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"),
buttons: buttons
),
id: "other options"
)
} label: {
SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI)
}
}
private func clearNoteFolderButton() -> some View { private func clearNoteFolderButton() -> some View {
Button { Button {
AlertManager.shared.showAlert(clearNoteFolderAlert()) AlertManager.shared.showAlert(clearNoteFolderAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
@ -417,7 +287,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: { } label: {
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI) Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
} }
.tint(Color.yellow) .tint(Color.yellow)
} }
@ -426,33 +296,32 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat) ContactRequestView(contactRequest: contactRequest, chat: chat)
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } } label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary) .tint(theme.colors.primary)
Button { Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) Label("Accept incognito", systemImage: "theatermasks")
} }
.tint(.indigo) .tint(.indigo)
Button { Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: { } label: {
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI) Label("Reject", systemImage: "multiply")
} }
.tint(.red) .tint(.red)
} }
.contentShape(Rectangle()) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@ -463,24 +332,24 @@ struct ChatListNavLink: View {
private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
ContactConnectionView(chat: chat) ContactConnectionView(chat: chat)
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in
AlertManager.shared.showAlertMsg(title: a.title, message: a.message) AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
}) })
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
Button { Button {
showContactConnectionInfo = true showContactConnectionInfo = true
} label: { } label: {
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI) Label("Name", systemImage: "pencil")
} }
.tint(theme.colors.primary) .tint(theme.colors.primary)
} }
.frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) { .appSheet(isPresented: $showContactConnectionInfo) {
Group { Group {
if case let .contactConnection(contactConnection) = chat.chatInfo { if case let .contactConnection(contactConnection) = chat.chatInfo {
@ -490,20 +359,14 @@ struct ChatListNavLink: View {
} }
} }
} }
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
showContactConnectionInfo = true showContactConnectionInfo = true
} }
} }
private var deleteLabel: some View {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" Alert(
return Alert( title: Text("Delete group?"),
title: Text(label),
message: deleteGroupAlertMessage(groupInfo), message: deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) { primaryButton: .destructive(Text("Delete")) {
Task { await deleteChat(chat) } Task { await deleteChat(chat) }
@ -512,25 +375,8 @@ struct ChatListNavLink: View {
) )
} }
private func archiveAllReportsAlert() -> Alert { private func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
Alert( groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
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 { private func clearChatAlert() -> Alert {
@ -556,15 +402,9 @@ struct ChatListNavLink: View {
} }
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" Alert(
let messageLabel: LocalizedStringKey = ( title: Text("Leave group?"),
groupInfo.businessChat == nil message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
? "You will stop receiving messages from this group. Chat history will be preserved."
: "You will stop receiving messages from this chat. Chat history will be preserved."
)
return Alert(
title: Text(titleLabel),
message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) { primaryButton: .destructive(Text("Leave")) {
Task { await leaveGroup(groupInfo.groupId) } Task { await leaveGroup(groupInfo.groupId) }
}, },
@ -572,6 +412,28 @@ struct ChatListNavLink: View {
) )
} }
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Contact is not connected yet!"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .cancel(),
secondaryButton: .destructive(Text("Delete Contact")) {
removePendingContact(chat, contact)
}
)
}
private func groupInvitationAcceptedAlert() -> Alert { private func groupInvitationAcceptedAlert() -> Alert {
Alert( Alert(
title: Text("Joining group"), title: Text("Joining group"),
@ -579,59 +441,54 @@ struct ChatListNavLink: View {
) )
} }
private func invalidJSONPreview(_ json: Data?) -> some View { private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .destructive(Text("Delete")) {
removePendingContact(chat, contact)
},
secondaryButton: .cancel()
)
}
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
DispatchQueue.main.async {
chatModel.removeChat(contact.id)
}
} catch let error {
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
}
}
}
private func invalidJSONPreview(_ json: String) -> some View {
Text("invalid chat data") Text("invalid chat data")
.foregroundColor(.red) .foregroundColor(.red)
.padding(4) .padding(4)
.frameCompat(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true } .onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) { .appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(dataToString(json)) invalidJSONView(json)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
} }
} }
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) { await MainActor.run {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) chatModel.chatId = contact.id
} }
} }
} }
} }
} }
extension View {
@inline(__always)
@ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View {
if #available(iOS 16, *) {
self.frame(height: height)
} else {
VStack(spacing: 0) {
Divider()
.padding(.leading, 16)
self
.frame(height: height)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
Alert( Alert(
title: Text("Delete pending connection?"), title: Text("Delete pending connection?"),
@ -658,14 +515,15 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
) )
} }
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool { func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert { if let alert = alert {
showAlert(alert) AlertManager.shared.showAlert(alert)
return false return false
} else if let contact = contact { } else if let contact = contact {
await MainActor.run { await MainActor.run {
ChatModel.shared.updateContact(contact) ChatModel.shared.updateContact(contact)
AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }
return true return true
} }
@ -707,7 +565,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
} }
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatError, if let r = error as? ChatResponse,
let alert = getNetworkErrorAlert(r) { let alert = getNetworkErrorAlert(r) {
return alert return alert
} else { } else {
@ -722,15 +580,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
ChatListNavLink(chat: Chat( ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), parentSheet: .constant(nil)) ))
ChatListNavLink(chat: Chat( ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), parentSheet: .constant(nil)) ))
ChatListNavLink(chat: Chat( ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest, chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: [] chatItems: []
), parentSheet: .constant(nil)) ))
} }
.previewLayout(.fixed(width: 360, height: 82)) .previewLayout(.fixed(width: 360, height: 82))
} }

File diff suppressed because it is too large Load diff

View file

@ -12,124 +12,51 @@ import SimpleXChat
struct ChatPreviewView: View { struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool @Binding var progressByTimeout: Bool
@State var deleting: Bool = false @State var deleting: Bool = false
var darkGreen = Color(red: 0, green: 0.5, blue: 0) var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@State private var activeContentPreview: ActiveContentPreview? = nil
@State private var showFullscreenGallery: Bool = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View { var body: some View {
let cItem = chat.chatItems.last let cItem = chat.chatItems.last
return ZStack { return HStack(spacing: 8) {
HStack(spacing: 8) { ZStack(alignment: .bottomTrailing) {
ZStack(alignment: .bottomTrailing) { ChatInfoImage(chat: chat, size: 63)
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) chatPreviewImageOverlayIcon()
chatPreviewImageOverlayIcon() .padding([.bottom, .trailing], 1)
.padding([.bottom, .trailing], 1) }
} .padding(.leading, 4)
.padding(.leading, 4)
VStack(spacing: 0) {
let chatTs = if let cItem { HStack(alignment: .top) {
cItem.meta.itemTs chatPreviewTitle()
} else {
chat.chatInfo.chatTs
}
VStack(spacing: 0) {
HStack(alignment: .top) {
chatPreviewTitle()
Spacer()
(formatTimestampText(chatTs))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(theme.colors.secondary)
.padding(.top, 4)
}
.padding(.bottom, 4)
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
let chat = activeContentPreview?.chat ?? chat
let ci = activeContentPreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatStatusImage()
.padding(.top, dynamicChatInfoSize * 1.44)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Spacer() Spacer()
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(theme.colors.secondary)
.padding(.top, 4)
} }
.frame(maxHeight: .infinity) .padding(.bottom, 4)
} .padding(.horizontal, 8)
.opacity(deleting ? 0.4 : 1)
.padding(.bottom, -8) ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem)
if deleting { chatStatusImage()
ProgressView() .padding(.top, 26)
.scaleEffect(2) .frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.trailing, 8)
Spacer()
} }
.frame(maxHeight: .infinity)
} }
.padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in .onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains deleting = contains
// Stop voice when deleting the chat
if contains, let ci = activeContentPreview?.ci {
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
}
}
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if case .voice = activeContentPreview?.mc, playing == nil {
activeContentPreview = nil
} else if activeContentPreview == nil {
if case .image = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .video = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
} else if case .voice = activeContentPreview?.mc {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeContentPreview = nil
}
} else if !showFullscreenGallery {
activeContentPreview = nil
}
} }
} }
@ -143,7 +70,6 @@ struct ChatPreviewView: View {
} }
case let .group(groupInfo): case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memRejected: inactiveIcon()
case .memLeft: inactiveIcon() case .memLeft: inactiveIcon()
case .memRemoved: inactiveIcon() case .memRemoved: inactiveIcon()
case .memGroupDeleted: inactiveIcon() case .memGroupDeleted: inactiveIcon()
@ -154,7 +80,7 @@ struct ChatPreviewView: View {
} }
} }
private func inactiveIcon() -> some View { @ViewBuilder private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill") Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65)) .foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground))) .background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
@ -169,7 +95,7 @@ struct ChatPreviewView: View {
let v = previewTitle(t) let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
case .memAccepted, .memRejected: v.foregroundColor(theme.colors.secondary) case .memAccepted: v.foregroundColor(theme.colors.secondary)
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v } default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
} }
default: previewTitle(t) default: previewTitle(t)
@ -181,100 +107,61 @@ struct ChatPreviewView: View {
} }
private var verifiedIcon: Text { private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
} }
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) { 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 let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.if(hasSecrets, transform: hiddenSecretsView)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8) .padding(.leading, 8)
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .padding(.trailing, 36)
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft { if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy) t.privacySensitive(true).redacted(reason: .privacy)
} else { } else {
t t
} }
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) let s = chat.chatStats
} if s.unreadCount > 0 || s.unreadChat {
} unreadCountText(s.unreadCount)
.font(.caption)
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
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) .foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding) .padding(.horizontal, 4)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) .frame(minWidth: 18, minHeight: 18)
.background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner) .cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
} }
.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: iconSize, height: iconSize)
.foregroundColor(iconColor)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.padding(.trailing, 1)
.foregroundColor(theme.colors.secondary.opacity(0.65))
} else {
Color.clear.frame(width: 0)
}
}
private func mentionColor(_ chat: Chat) -> Color {
switch chat.chatInfo.chatSettings?.enableNtfs {
case .all: theme.colors.primary
case .mentions: theme.colors.primary
default: theme.colors.secondary
} }
} }
private func messageDraft(_ draft: ComposeState) -> (Text, Bool) { private func messageDraft(_ draft: ComposeState) -> Text {
let msg = draft.message let msg = draft.message
let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background)) return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + attachment()
+ attachment() + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ Text(AttributedString(r.string)),
r.hasSecrets)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
Text(Image(systemName: s)).foregroundColor(color) + textSpace Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
} }
func attachment() -> Text { func attachment() -> Text {
switch draft.preview { switch draft.preview {
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + textSpace case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
case .mediaPreviews: return image("photo") case .mediaPreviews: return image("photo")
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration)) case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
default: return Text("") default: return Text("")
@ -282,24 +169,19 @@ struct ChatPreviewView: View {
} }
} }
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
let r = messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return (Text(AttributedString(r.string)), r.hasSecrets)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // 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 // can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String { func markedDeletedText() -> String {
if cItem.meta.itemDeleted != nil, cItem.isReport { switch cItem.meta.itemDeleted {
"archived report" case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
} else { case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
switch cItem.meta.itemDeleted { case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
}
} }
} }
@ -312,29 +194,20 @@ struct ChatPreviewView: View {
default: return nil default: return nil
} }
} }
func prefix() -> NSAttributedString? {
switch cItem.content.msgContent {
case let .report(_, reason): reason.attrString
default: nil
}
}
} }
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft { if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
let (t, hasSecrets) = messageDraft(draft) chatPreviewLayout(messageDraft(draft), draft: true)
chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else if let cItem = cItem { } else if let cItem = cItem {
let (t, hasSecrets) = chatItemPreview(cItem) chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else { } else {
switch (chat.chatInfo) { switch (chat.chatInfo) {
case let .direct(contact): case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect") chatPreviewInfoText("Tap to Connect")
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
} else if !contact.sndReady && contact.activeConn != nil { } else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv { if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message") chatPreviewInfoText("send direct message")
} else if contact.active { } else if contact.active {
@ -343,7 +216,6 @@ struct ChatPreviewView: View {
} }
case let .group(groupInfo): case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memRejected: chatPreviewInfoText("rejected")
case .memInvited: groupInvitationPreviewText(groupInfo) case .memInvited: groupInvitationPreviewText(groupInfo)
case .memAccepted: chatPreviewInfoText("connecting…") case .memAccepted: chatPreviewInfoText("connecting…")
default: EmptyView() default: EmptyView()
@ -353,59 +225,13 @@ struct ChatPreviewView: View {
} }
} }
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.foregroundColor(Color.white)
.font(.system(size: 15, weight: .black))
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
openBrowserAlert(uri: preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
: chatPreviewInfoText("you are invited to group") : chatPreviewInfoText("you are invited to group")
} }
private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
Text(text) Text(text)
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8) .padding([.leading, .trailing], 8)
@ -417,121 +243,61 @@ struct ChatPreviewView: View {
case .sndErrorAuth, .sndError: case .sndErrorAuth, .sndError:
return Text(Image(systemName: "multiply")) return Text(Image(systemName: "multiply"))
.font(.caption) .font(.caption)
.foregroundColor(.red) + textSpace .foregroundColor(.red) + Text(" ")
case .sndWarning: case .sndWarning:
return Text(Image(systemName: "exclamationmark.triangle.fill")) return Text(Image(systemName: "exclamationmark.triangle.fill"))
.font(.caption) .font(.caption)
.foregroundColor(.orange) + textSpace .foregroundColor(.orange) + Text(" ")
default: return Text("") default: return Text("")
} }
} }
@ViewBuilder private func chatStatusImage() -> some View { @ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
if contact.active && contact.activeConn != nil { if contact.active && contact.activeConn != nil {
NetworkStatusView(contact: contact, size: size) switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else { } else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
} }
case .group: case .group:
if progressByTimeout { if progressByTimeout {
ProgressView() ProgressView()
} else if chat.chatStats.reportsCount > 0 {
groupReportsIcon(size: size * 0.8)
} else { } else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
} }
default: default:
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
}
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 { @ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
if incognito { if incognito {
Image(systemName: "theatermasks") Image(systemName: "theatermasks")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: size, height: size) .frame(width: 22, height: 22)
.foregroundColor(secondaryColor) .foregroundColor(secondaryColor)
} else { } else {
EmptyView() EmptyView()
} }
} }
func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(.red)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
.padding(.vertical, size / 6)
.padding(.leading, 8)
}
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.padding(.vertical, size / 7)
.padding(.leading, 5)
}
func unreadCountText(_ n: Int) -> Text { func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "") Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
} }
private struct ActiveContentPreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider { struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {

View file

@ -14,7 +14,6 @@ struct ContactConnectionInfo: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection @State var contactConnection: PendingContactConnection
@State private var showShortLink: Bool = true
@State private var alert: CCInfoAlert? @State private var alert: CCInfoAlert?
@State private var localAlias = "" @State private var localAlias = ""
@State private var showIncognitoSheet = false @State private var showIncognitoSheet = false
@ -22,7 +21,7 @@ struct ContactConnectionInfo: View {
enum CCInfoAlert: Identifiable { enum CCInfoAlert: Identifiable {
case deleteInvitationAlert case deleteInvitationAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@ -62,19 +61,14 @@ struct ContactConnectionInfo: View {
} }
if contactConnection.initiated, if contactConnection.initiated,
let connLinkInv = contactConnection.connLinkInv { let connReqInv = contactConnection.connReqInv {
SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink) SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
.id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))")
incognitoEnabled() incognitoEnabled()
shareLinkButton(connLinkInv, short: showShortLink) shareLinkButton(connReqInv, theme.colors.secondary)
oneTimeLinkLearnMoreButton() oneTimeLinkLearnMoreButton(theme.colors.secondary)
} else { } else {
incognitoEnabled() incognitoEnabled()
oneTimeLinkLearnMoreButton() oneTimeLinkLearnMoreButton(theme.colors.secondary)
}
} header: {
if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil {
ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink)
} }
} footer: { } footer: {
sharedProfileInfo(contactConnection.incognito) sharedProfileInfo(contactConnection.incognito)
@ -108,7 +102,7 @@ struct ContactConnectionInfo: View {
} success: { } success: {
dismiss() dismiss()
} }
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@ -173,22 +167,26 @@ struct ContactConnectionInfo: View {
} }
} }
private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View { private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
Button { Button {
showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)]) showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: { } label: {
Label("Share 1-time link", systemImage: "square.and.arrow.up") settingsRow("square.and.arrow.up", color: secondaryColor) {
Text("Share 1-time link")
}
} }
} }
private func oneTimeLinkLearnMoreButton() -> some View { private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
NavigationLink { NavigationLink {
AddContactLearnMore(showTitle: false) AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link") .navigationTitle("One-time invitation link")
.modifier(ThemedBackground()) .modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Learn more", systemImage: "info.circle") settingsRow("info.circle", color: secondaryColor) {
Text("Learn more")
}
} }
} }

View file

@ -13,9 +13,9 @@ struct ContactConnectionView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = "" @State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
var body: some View { var body: some View {
if case let .contactConnection(conn) = chat.chatInfo { if case let .contactConnection(conn) = chat.chatInfo {
@ -31,6 +31,7 @@ struct ContactConnectionView: View {
.scaledToFill() .scaledToFill()
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme)) .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
.onTapGesture { showContactConnectionInfo = true }
} }
.frame(width: 63, height: 63) .frame(width: 63, height: 63)
.padding(.leading, 4) .padding(.leading, 4)
@ -61,7 +62,7 @@ struct ContactConnectionView: View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Text(contactConnection.description) Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize) incognitoIcon(contactConnection.incognito, theme.colors.secondary)
.padding(.top, 26) .padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
@ -70,6 +71,9 @@ struct ContactConnectionView: View {
Spacer() Spacer()
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.appSheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
} }
} }
} }

View file

@ -12,13 +12,12 @@ import SimpleXChat
struct ContactRequestView: View { struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var contactRequest: UserContactRequest var contactRequest: UserContactRequest
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) ChatInfoImage(chat: chat, size: 63)
.padding(.leading, 4) .padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) { HStack(alignment: .top) {

View file

@ -1,51 +0,0 @@
//
// OneHandUICard.swift
// SimpleX (iOS)
//
// Created by EP on 06/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct OneHandUICard: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@State private var showOneHandUIAlert = false
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 8) {
Text("Toggle chat list:").font(.title3)
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
}
Image(systemName: "multiply")
.foregroundColor(theme.colors.secondary)
.onTapGesture {
showOneHandUIAlert = true
}
}
.padding()
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
message: Text("You can change it in Appearance settings."),
dismissButton: .default(Text("Ok")) {
withAnimation {
oneHandUICardShown = true
}
}
)
}
}
}
#Preview {
OneHandUICard()
}

View file

@ -12,12 +12,11 @@ import SimpleXChat
struct ServersSummaryView: View { struct ServersSummaryView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State private var serversSummary: PresentedServersSummary? = nil @Binding var serversSummary: PresentedServersSummary?
@State private var selectedUserCategory: PresentedUserCategory = .allUsers @State private var selectedUserCategory: PresentedUserCategory = .allUsers
@State private var selectedServerType: PresentedServerType = .smp @State private var selectedServerType: PresentedServerType = .smp
@State private var selectedSMPServer: String? = nil @State private var selectedSMPServer: String? = nil
@State private var selectedXFTPServer: String? = nil @State private var selectedXFTPServer: String? = nil
@State private var timer: Timer? = nil
@State private var alert: SomeAlert? @State private var alert: SomeAlert?
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@ -48,36 +47,10 @@ struct ServersSummaryView: View {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 { if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 {
selectedUserCategory = .currentUser selectedUserCategory = .currentUser
} }
getServersSummary()
startTimer()
}
.onDisappear {
stopTimer()
} }
.alert(item: $alert) { $0.alert } .alert(item: $alert) { $0.alert }
} }
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getServersSummary()
}
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func shareButton() -> some View { private func shareButton() -> some View {
Button { Button {
if let serversSummary = serversSummary { if let serversSummary = serversSummary {
@ -102,8 +75,8 @@ struct ServersSummaryView: View {
Group { Group {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
Picker("User selection", selection: $selectedUserCategory) { Picker("User selection", selection: $selectedUserCategory) {
Text("All profiles").tag(PresentedUserCategory.allUsers) Text("All users").tag(PresentedUserCategory.allUsers)
Text("Current profile").tag(PresentedUserCategory.currentUser) Text("Current user").tag(PresentedUserCategory.currentUser)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
@ -203,13 +176,12 @@ struct ServersSummaryView: View {
Section { Section {
infoRow("Active connections", numOrDash(totals.subs.ssActive)) infoRow("Active connections", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total)) infoRow("Total", numOrDash(totals.subs.total))
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
} header: { } header: {
HStack { HStack {
Text("Message reception") Text("Message reception")
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess) SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
if showSubscriptionPercentage { if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess) SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
} }
} }
} }
@ -245,7 +217,7 @@ struct ServersSummaryView: View {
} }
} }
private func smpServersListView( @ViewBuilder private func smpServersListView(
_ servers: [SMPServerSummary], _ servers: [SMPServerSummary],
_ statsStartedAt: Date, _ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil, _ header: LocalizedStringKey? = nil,
@ -256,7 +228,7 @@ struct ServersSummaryView: View {
? serverAddress($0.smpServer) < serverAddress($1.smpServer) ? serverAddress($0.smpServer) < serverAddress($1.smpServer)
: $0.hasSubs && !$1.hasSubs : $0.hasSubs && !$1.hasSubs
} }
return Section { Section {
ForEach(sortedServers) { server in ForEach(sortedServers) { server in
smpServerView(server, statsStartedAt) smpServerView(server, statsStartedAt)
} }
@ -287,9 +259,9 @@ struct ServersSummaryView: View {
if let subs = srvSumm.subs { if let subs = srvSumm.subs {
Spacer() Spacer()
if showSubscriptionPercentage { if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess) SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
} }
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess) SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
} else if let sess = srvSumm.sessions { } else if let sess = srvSumm.sessions {
Spacer() Spacer()
Image(systemName: "arrow.up.circle") Image(systemName: "arrow.up.circle")
@ -318,14 +290,14 @@ struct ServersSummaryView: View {
return onionHosts == .require ? .indigo : .accentColor return onionHosts == .require ? .indigo : .accentColor
} }
private func xftpServersListView( @ViewBuilder private func xftpServersListView(
_ servers: [XFTPServerSummary], _ servers: [XFTPServerSummary],
_ statsStartedAt: Date, _ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil, _ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil _ footer: LocalizedStringKey? = nil
) -> some View { ) -> some View {
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) } let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
return Section { Section {
ForEach(sortedServers) { server in ForEach(sortedServers) { server in
xftpServerView(server, statsStartedAt) xftpServerView(server, statsStartedAt)
} }
@ -383,7 +355,6 @@ struct ServersSummaryView: View {
Task { Task {
do { do {
try await resetAgentServersStats() try await resetAgentServersStats()
getServersSummary()
} catch let error { } catch let error {
alert = SomeAlert( alert = SomeAlert(
alert: mkAlert( alert: mkAlert(
@ -407,18 +378,12 @@ struct ServersSummaryView: View {
struct SubscriptionStatusIndicatorView: View { struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs var subs: SMPServerSubs
var hasSess: Bool var sess: ServerSessions
var body: some View { var body: some View {
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage( let onionHosts = networkUseOnionHostsGroupDefault.get()
online: m.networkInfo.online, let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
hasSess: hasSess,
primaryColor: theme.colors.primary
)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue) Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color) .foregroundColor(color)
@ -431,52 +396,42 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View { struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs var subs: SMPServerSubs
var hasSess: Bool var sess: ServerSessions
var body: some View { var body: some View {
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage( let onionHosts = networkUseOnionHostsGroupDefault.get()
online: m.networkInfo.online, let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
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)))%") Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.caption) .font(.caption)
} }
} }
func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) { func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double { func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1 n >= 1 ? 1
: n <= 0 ? 0 : n <= 0 ? 0
: (n * 4).rounded() / 4 : (n * 4).rounded() / 4
} }
let activeColor: Color = usesProxy ? .indigo : primaryColor let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0) let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive) let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online return online && subs.total > 0
? noConnColorAndPercent ? (
: ( subs.ssActive == 0
subs.total == 0 && !hasSess ? (
? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (
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
)
) )
: ( // ssActive > 0
sess.ssConnected == 0
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
) )
: noConnColorAndPercent
} }
struct SMPServerSummaryView: View { struct SMPServerSummaryView: View {
@ -491,6 +446,15 @@ struct SMPServerSummaryView: View {
Section("Server address") { Section("Server address") {
Text(summary.smpServer) Text(summary.smpServer)
.textSelection(.enabled) .textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
} }
if let stats = summary.stats { if let stats = summary.stats {
@ -517,9 +481,9 @@ struct SMPServerSummaryView: View {
} header: { } header: {
HStack { HStack {
Text("Message reception") Text("Message reception")
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess) SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
if showSubscriptionPercentage { if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess) SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
} }
} }
} }
@ -587,7 +551,7 @@ struct SMPStatsView: View {
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
} }
} }
} }
@ -624,7 +588,7 @@ struct DetailedSMPStatsView: View {
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24) infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24) infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
} }
Section("Connections") { Section {
infoRow("Created", numOrDash(stats._connCreated)) infoRow("Created", numOrDash(stats._connCreated))
infoRow("Secured", numOrDash(stats._connCreated)) infoRow("Secured", numOrDash(stats._connCreated))
infoRow("Completed", numOrDash(stats._connCompleted)) infoRow("Completed", numOrDash(stats._connCompleted))
@ -633,12 +597,8 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts) infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored)) infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs)) infoRow("Subscription errors", numOrDash(stats._connSubErrs))
}
Section {
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
} header: { } header: {
Text("Connection notifications") Text("Connections")
} footer: { } footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") Text("Starting from \(localTimestamp(statsStartedAt)).")
} }
@ -670,6 +630,15 @@ struct XFTPServerSummaryView: View {
Section("Server address") { Section("Server address") {
Text(summary.xftpServer) Text(summary.xftpServer)
.textSelection(.enabled) .textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
} }
if let stats = summary.stats { if let stats = summary.stats {
@ -703,7 +672,7 @@ struct XFTPStatsView: View {
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
} }
} }
} }
@ -742,5 +711,7 @@ struct DetailedXFTPStatsView: View {
} }
#Preview { #Preview {
ServersSummaryView() ServersSummaryView(
serversSummary: Binding.constant(nil)
)
} }

View file

@ -1,408 +0,0 @@
//
// TagListView.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 31/12/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
import ElegantEmojiPicker
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct TagListView: View {
var chat: Chat? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var editMode = EditMode.inactive
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
List {
Section {
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let text = tag.chatTagText
let emoji = tag.chatTagEmoji
let tagId = tag.chatTagId
let selected = chatTagsIds.contains(tagId)
HStack {
if let emoji {
Text(emoji)
} else {
Image(systemName: "tag")
}
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.contentShape(Rectangle())
.onTapGesture {
if let c = chat {
setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
} else {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
),
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
handler: { _ in
deleteTag(tagId)
}
)
]}
)
} label: {
Label("Delete", systemImage: "trash.fill")
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.background(
// isActive required to navigate to edit view from any possible tag edited in swipe action
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
if let params = tagEditorNavParams {
TagListEditor(
chat: params.chat,
tagId: params.tagId,
emoji: params.chatListTag?.emoji,
name: params.chatListTag?.text ?? ""
)
}
} label: {
EmptyView()
}
.opacity(0)
)
}
.onMove(perform: moveItem)
NavigationLink {
TagListEditor(chat: chat)
} label: {
Label("Create list", systemImage: "plus")
}
} header: {
if chat == nil {
editTagsButton()
.textCase(nil)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.modifier(ThemedBackground(grouped: true))
.environment(\.editMode, $editMode)
}
private func editTagsButton() -> some View {
if editMode.isEditing {
Button("Done") {
editMode = .inactive
dismiss()
}
} else {
Button("Edit") {
editMode = .active
}
}
}
private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
}
private func moveItem(from source: IndexSet, to destination: Int) {
Task {
do {
var tags = chatTagsModel.userTags
tags.move(fromOffsets: source, toOffset: destination)
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
await MainActor.run {
chatTagsModel.userTags = tags
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
Task {
try await apiDeleteChatTag(tagId: tagId)
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
chatTagsModel.activeFilter = nil
}
m.chats.forEach { c in
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
m.updateContact(contact)
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
group.chatTags = group.chatTags.filter({ $0 != tagId })
m.updateGroup(group)
}
}
}
}
}
}
private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
Task {
do {
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
let (userTags, chatTags) = try await apiSetChatTags(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagIds: tagIds
)
await MainActor.run {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
tm.userTags = userTags
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
tm.decTagsReadCount(tags)
}
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false)
closeSheet()
}
} catch let error {
showAlert(
NSLocalizedString("Error saving chat list", comment: "alert title"),
message: responseError(error)
)
}
}
}
struct EmojiPickerView: UIViewControllerRepresentable {
@Binding var selectedEmoji: String?
@Binding var showingPicker: Bool
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
var parent: EmojiPickerView
init(parent: EmojiPickerView) {
self.parent = parent
}
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
parent.selectedEmoji = emoji?.emoji
parent.showingPicker = false
picker.dismiss(animated: true)
}
// Called when the picker is dismissed manually (without selection)
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
parent.showingPicker = false
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
picker.presentationController?.delegate = context.coordinator
let viewController = UIViewController()
DispatchQueue.main.async {
if let topVC = getTopViewController() {
topVC.present(picker, animated: true)
}
}
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No need to update the controller after creation
}
}
struct TagListEditor: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var theme: AppTheme
var chat: Chat? = nil
var tagId: Int64? = nil
var emoji: String?
var name: String = ""
@State private var newEmoji: String?
@State private var newName: String = ""
@State private var isPickerPresented = false
@State private var saving: Bool?
var body: some View {
VStack {
List {
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
tag.chatTagId != tagId &&
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
}
Section {
HStack {
Button {
isPickerPresented = true
} label: {
if let newEmoji {
Text(newEmoji)
} else {
Image(systemName: "face.smiling")
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $newName)
}
Button {
saving = true
if let tId = tagId {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
} else {
createChatTag()
}
} label: {
Text(
chat != nil
? "Add to list"
: "Save list"
)
}
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
} footer: {
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text("List name and emoji should be different for all lists.")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if isPickerPresented {
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
newEmoji = emoji
newName = name
}
}
var trimmedName: String {
newName.trimmingCharacters(in: .whitespaces)
}
private func createChatTag() {
Task {
do {
let text = trimmedName
let userTags = try await apiCreateChatTag(
tag: ChatTagData(emoji: newEmoji , text: text)
)
await MainActor.run {
saving = false
chatTagsModel.userTags = userTags
}
if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
} else {
await MainActor.run { dismiss() }
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
Task {
do {
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
await MainActor.run {
saving = false
for i in 0..<chatTagsModel.userTags.count {
if chatTagsModel.userTags[i].chatTagId == tagId {
chatTagsModel.userTags[i] = ChatTag(
chatTagId: tagId,
chatTagText: chatTagData.text,
chatTagEmoji: chatTagData.emoji
)
}
}
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more