Compare commits

..

No commits in common. "stable" and "v6.1.0-beta.4" have entirely different histories.

1019 changed files with 46093 additions and 131835 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

@ -22,58 +22,17 @@ on:
- "README.md" - "README.md"
- "PRIVACY.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 +42,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 +52,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 +233,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 +326,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 +344,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 }}

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

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!
@ -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=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform: There is also a group [#simplex-devs](https://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:
@ -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,6 +141,15 @@ 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!
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations ## Please support us with your 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,34 @@ 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) [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
@ -310,13 +314,12 @@ What is already implemented:
15. Manual messaging queue rotations to move conversation to another SMP relay. 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). 16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
17. Local files encryption. 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. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
3. Reproducible clients builds this is a complex problem, but we are aiming to have it in 2025 at least partially. 3. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers ## For developers
@ -385,11 +388,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 +409,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

@ -17,7 +17,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
removePasscodesIfReinstalled() removePasscodesIfReinstalled()
prepareForLaunch() prepareForLaunch()
deleteOldChatArchive()
return true return true
} }
@ -54,7 +53,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,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,8 +29,7 @@ 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 showWhatsNew = false
@State private var noticesSheetItem: NoticesSheet? = nil
@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
@ -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.
@ -209,7 +197,7 @@ 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()
@ -262,8 +250,7 @@ struct ContentView: View {
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet) ChatListView(activeUserPickerSheet: $chatListUserPickerSheet).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 +259,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 +279,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) {
@ -443,12 +401,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 +423,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

View file

@ -30,18 +30,9 @@ actor TerminalItems {
} }
} }
func addCommand<R: ChatAPIResult>(_ start: Date, _ cmd: ChatCommand, _ res: APIResult<R>) async { func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
await add(.cmd(start, cmd)) await add(.cmd(start, cmd))
await addResult(res) await add(.resp(.now, resp))
}
func addResult<R: ChatAPIResult>(_ res: APIResult<R>) async {
let item: TerminalItem = switch res {
case let .result(r): .res(.now, r)
case let .error(e): .err(.now, e)
case let .invalid(type, json): .bad(.now, type, json)
}
await add(item)
} }
} }
@ -62,20 +53,11 @@ class ItemsModel: ObservableObject {
var itemAdded = false { var itemAdded = false {
willSet { publisher.send() } willSet { publisher.send() }
} }
let chatState = ActiveChatState()
// Publishes directly to `objectWillChange` publisher, // Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling // this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false @Published var isLoading = false
@Published var showLoadingProgress: ChatId? = nil @Published var showLoadingProgress = false
private var navigationTimeoutTask: Task<Void, Never>? = nil
private var loadChatTask: Task<Void, Never>? = nil
var lastItemsLoaded: Bool {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
init() { init() {
publisher publisher
@ -85,149 +67,37 @@ class ItemsModel: ObservableObject {
} }
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
navigationTimeoutTask?.cancel() let navigationTimeout = Task {
loadChatTask?.cancel()
navigationTimeoutTask = Task {
do { do {
try await Task.sleep(nanoseconds: 250_000000) try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run { await MainActor.run {
ChatModel.shared.chatId = chatId
willNavigate() willNavigate()
ChatModel.shared.chatId = chatId
} }
} catch {} } catch {}
} }
loadChatTask = Task { let progressTimeout = Task {
await MainActor.run { self.isLoading = true } do {
// try? await Task.sleep(nanoseconds: 1000_000000) try await Task.sleep(nanoseconds: 1500_000000)
await loadChat(chatId: chatId) await MainActor.run { showLoadingProgress = true }
if !Task.isCancelled { } catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run { await MainActor.run {
self.isLoading = false self.isLoading = false
self.showLoadingProgress = nil self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
} }
} }
} }
} }
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
navigationTimeoutTask?.cancel()
loadChatTask?.cancel()
loadChatTask = Task {
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
if !Task.isCancelled {
await MainActor.run {
if openAroundItemId == nil {
ChatModel.shared.chatId = chatId
}
}
}
}
}
}
class ChatTagsModel: ObservableObject {
static let shared = ChatTagsModel()
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
@Published var presetTags: [PresetTag:Int] = [:]
@Published var unreadTags: [Int64:Int] = [:]
func updateChatTags(_ chats: [Chat]) {
let tm = ChatTagsModel.shared
var newPresetTags: [PresetTag:Int] = [:]
var newUnreadTags: [Int64:Int] = [:]
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) {
newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
}
}
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
for tag in tags {
newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1
}
}
}
presetTags = newPresetTags
unreadTags = newUnreadTags
clearActiveChatFilterIfNeeded()
}
func updateChatFavorite(favorite: Bool, wasFavorite: Bool) {
let count = presetTags[.favorites]
if favorite && !wasFavorite {
presetTags[.favorites] = (count ?? 0) + 1
} else if !favorite && wasFavorite, let count {
presetTags[.favorites] = max(0, count - 1)
clearActiveChatFilterIfNeeded()
}
}
func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
presetTags[tag] = (presetTags[tag] ?? 0) + 1
}
}
}
func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
if let count = presetTags[tag] {
if count > 1 {
presetTags[tag] = count - 1
} else {
presetTags.removeValue(forKey: tag)
}
}
}
}
clearActiveChatFilterIfNeeded()
}
func markChatTagRead(_ chat: Chat) -> Void {
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
decTagsReadCount(tags)
}
}
func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void {
guard let tags = chat.chatInfo.chatTags else { return }
let nowUnread = chat.unreadTag
if nowUnread && !wasUnread {
for tag in tags {
unreadTags[tag] = (unreadTags[tag] ?? 0) + 1
}
} else if !nowUnread && wasUnread {
decTagsReadCount(tags)
}
}
func decTagsReadCount(_ tags: [Int64]) -> Void {
for tag in tags {
if let count = unreadTags[tag] {
unreadTags[tag] = max(0, count - 1)
}
}
}
func changeGroupReportsTag(_ by: Int = 0) {
if by == 0 { return }
presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by)
clearActiveChatFilterIfNeeded()
}
func clearActiveChatFilterIfNeeded() {
let clear = switch activeFilter {
case let .presetTag(tag): (presetTags[tag] ?? 0) == 0
case let .userTag(tag): !userTags.contains(tag)
case .unread, nil: false
}
if clear { activeFilter = nil }
}
} }
class NetworkModel: ObservableObject { class NetworkModel: ObservableObject {
@ -277,7 +147,6 @@ final class ChatModel: ObservableObject {
@Published var chatDbEncrypted: Bool? @Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult? @Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false @Published var ctrlInitInProgress: Bool = false
@Published var notificationResponse: UNNotificationResponse?
// local authentication // local authentication
@Published var contentViewAccessAuthenticated: Bool = false @Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest? @Published var laRequest: LocalAuthRequest?
@ -286,7 +155,6 @@ final class ChatModel: ObservableObject {
@Published var deletedChats: Set<String> = [] @Published var deletedChats: Set<String> = []
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var openAroundItemId: ChatItem.ID? = nil
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GMember] = []
@ -301,7 +169,6 @@ final class ChatModel: ObservableObject {
@Published var deviceToken: DeviceToken? @Published var deviceToken: DeviceToken?
@Published var savedToken: DeviceToken? @Published var savedToken: DeviceToken?
@Published var tokenRegistered = false @Published var tokenRegistered = false
@Published var reRegisterTknStatus: NtfTknStatus? = nil
@Published var tokenStatus: NtfTknStatus? @Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off @Published var notificationMode = NotificationsMode.off
@Published var notificationServer: String? @Published var notificationServer: String?
@ -325,8 +192,6 @@ final class ChatModel: ObservableObject {
@Published var draft: ComposeState? @Published var draft: ComposeState?
@Published var draftChatId: String? @Published var draftChatId: String?
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) @Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
// usage conditions
@Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary<Int64, () -> Void> = [:] var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@ -476,7 +341,6 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo) updateChatInfo(cInfo)
} else if addMissing { } else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: [])) addChat(Chat(chatInfo: cInfo, chatItems: []))
ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
} }
} }
@ -534,7 +398,7 @@ final class ChatModel: ObservableObject {
[cItem] [cItem]
} }
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
} }
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else { } else {
@ -580,7 +444,6 @@ final class ChatModel: ObservableObject {
ci.meta.itemStatus = status ci.meta.itemStatus = status
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
im.itemAdded = true im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate() ChatItemDummyModel.shared.sendUpdate()
return true return true
@ -615,7 +478,7 @@ final class ChatModel: ObservableObject {
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew { if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
} }
// update previews // update previews
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
@ -626,54 +489,14 @@ final class ChatModel: ObservableObject {
// remove from current chat // remove from current chat
if chatId == cInfo.id { if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withAnimation { _ = withAnimation {
let item = im.reversedChatItems.remove(at: i) im.reversedChatItems.remove(at: i)
im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
} }
} }
} }
VoiceItemState.stopVoiceInChatView(cInfo, cItem) VoiceItemState.stopVoiceInChatView(cInfo, cItem)
} }
func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
// this should not happen, only another member can "remove" user, user can only "leave" (another event).
if byMember.groupMemberId == groupInfo.membership.groupMemberId {
logger.debug("exiting removeMemberItems")
return
}
if chatId == groupInfo.id {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(at: i, with: updatedItem)
}
}
} else if let chat = getChat(groupInfo.id),
chat.chatItems.count > 0,
let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
chat.chatItems = [updatedItem]
}
func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
let newContent: CIContent
if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
newContent = .sndModerated
} else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
newContent = .rcvModerated
} else {
return nil
}
var updatedItem = item
updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
if groupInfo.fullGroupPreferences.fullDelete.on {
updatedItem.content = newContent
}
if item.isActiveReport {
decreaseGroupReportsCounter(groupInfo.id)
}
return updatedItem
}
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous { if previous {
@ -716,7 +539,6 @@ final class ChatModel: ObservableObject {
let cItem = ChatItem.liveDummy(chatInfo.chatType) let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation { withAnimation {
im.reversedChatItems.insert(cItem, at: 0) im.reversedChatItems.insert(cItem, at: 0)
im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
im.itemAdded = true im.itemAdded = true
} }
return cItem return cItem
@ -736,37 +558,71 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true im.reversedChatItems.first?.isLiveDummy == true
} }
func markAllChatItemsRead(_ cInfo: ChatInfo) { func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview // update preview
_updateChat(cInfo.id) { chat in _updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
ChatTagsModel.shared.markChatTagRead(chat) self.updateFloatingButtons(unreadCount: 0)
chat.chatStats = ChatStats() chat.chatStats = ChatStats()
} }
// update current chat // update current chat
if chatId == cInfo.id { if chatId == cInfo.id {
var i = 0 markCurrentChatRead()
while i < im.reversedChatItems.count {
markChatItemRead_(i)
i += 1
}
im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
} }
} }
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < im.reversedChatItems.count {
markChatItemRead_(j)
j += 1
}
}
private func updateFloatingButtons(unreadCount: Int) {
let fbm = ChatView.FloatingButtonModel.shared
fbm.totalUnread = unreadCount
fbm.objectWillChange.send()
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
markCurrentChatRead(fromIndex: i)
_updateChat(cInfo.id) { chat in
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
}
// update preview
let markedCount = chat.chatStats.unreadCount - unreadBelow
if markedCount > 0 {
chat.chatStats.unreadCount -= markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
}
}
} else {
markChatItemsRead(cInfo)
}
}
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
_updateChat(cInfo.id) { chat in _updateChat(cInfo.id) { chat in
let wasUnread = chat.unreadTag
chat.chatStats.unreadChat = unreadChat chat.chatStats.unreadChat = unreadChat
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
} }
} }
func clearChat(_ cInfo: ChatInfo) { func clearChat(_ cInfo: ChatInfo) {
// clear preview // clear preview
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
chat.chatItems = [] chat.chatItems = []
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats() chat.chatStats = ChatStats()
chat.chatInfo = cInfo chat.chatInfo = cInfo
} }
@ -774,27 +630,18 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:] chatItemStatuses = [:]
im.reversedChatItems = [] im.reversedChatItems = []
im.chatState.clear()
} }
} }
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
if self.chatId == cInfo.id { if self.chatId == cInfo.id {
var unreadItemIds: Set<ChatItem.ID> = [] for itemId in itemIds {
var i = 0 if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
var ids = Set(itemIds)
while i < im.reversedChatItems.count && !ids.isEmpty {
let item = im.reversedChatItems[i]
if ids.contains(item.id) && item.isRcvNew {
markChatItemRead_(i) markChatItemRead_(i)
unreadItemIds.insert(item.id)
ids.remove(item.id)
} }
i += 1
} }
im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
} }
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
} }
private let unreadCollector = UnreadCollector() private let unreadCollector = UnreadCollector()
@ -802,16 +649,16 @@ final class ChatModel: ObservableObject {
class UnreadCollector { class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>() private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:] private var unreadCounts: [ChatId: Int] = [:]
init() { init() {
subject subject
.debounce(for: 1, scheduler: DispatchQueue.main) .debounce(for: 1, scheduler: DispatchQueue.main)
.sink { .sink {
let m = ChatModel.shared let m = ChatModel.shared
for (chatId, (unread, mentions)) in self.unreadCounts { for (chatId, count) in self.unreadCounts {
if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) { if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: unread, unreadMentions: mentions) m.changeUnreadCounter(i, by: count)
} }
} }
self.unreadCounts = [:] self.unreadCounts = [:]
@ -819,9 +666,11 @@ final class ChatModel: ObservableObject {
.store(in: &bag) .store(in: &bag)
} }
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) { func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0) if chatId == ChatModel.shared.chatId {
self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions) ChatView.FloatingButtonModel.shared.totalUnread += count
}
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
subject.send() subject.send()
} }
} }
@ -899,12 +748,8 @@ final class ChatModel: ObservableObject {
} }
} }
func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) { func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
let wasUnread = chats[chatIndex].unreadTag chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
let stats = chats[chatIndex].chatStats
chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count
chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
changeUnreadCounter(user: currentUser!, by: count) changeUnreadCounter(user: currentUser!, by: count)
} }
@ -912,13 +757,6 @@ final class ChatModel: ObservableObject {
changeUnreadCounter(user: user, by: 1) changeUnreadCounter(user: user, by: 1)
} }
func decreaseUnreadCounter(user: any UserLike, chat: Chat) {
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions
? chat.chatStats.unreadMentions
: chat.chatStats.unreadCount
decreaseUnreadCounter(user: user, by: by)
}
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by) changeUnreadCounter(user: user, by: -by)
} }
@ -931,41 +769,8 @@ final class ChatModel: ObservableObject {
} }
func totalUnreadCountForAllUsers() -> Int { func totalUnreadCountForAllUsers() -> Int {
var unread: Int = 0 chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
for chat in chats { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
switch chat.chatInfo.chatSettings?.enableNtfs {
case .all: unread += chat.chatStats.unreadCount
case .mentions: unread += chat.chatStats.unreadMentions
default: ()
}
}
for u in users {
if !u.user.activeUser {
unread += u.unreadCount
}
}
return unread
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
changeGroupReportsCounter(chatId, 1)
}
func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
changeGroupReportsCounter(chatId, -by)
}
private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
if by == 0 { return }
if let i = getChatIndex(chatId) {
let chat = chats[i]
let wasReportsCount = chat.chatStats.reportsCount
chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by)
let nowReportsCount = chat.chatStats.reportsCount
let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0
ChatTagsModel.shared.changeGroupReportsTag(by)
}
} }
// this function analyses "connected" events and assumes that each member will be there only once // this function analyses "connected" events and assumes that each member will be there only once
@ -1013,17 +818,12 @@ final class ChatModel: ObservableObject {
// returns the previous member in the same merge group and the count of members in this group // returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) { func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
let items = im.reversedChatItems
var prevMember: GroupMember? = nil var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = [] var memberIds: Set<Int64> = []
for i in range { for i in range {
if i < items.count { if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
if case let .groupRcv(m) = items[i].chatDir { if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } memberIds.insert(m.groupMemberId)
memberIds.insert(m.groupMemberId)
}
} else {
logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
} }
} }
return (prevMember, memberIds.count) return (prevMember, memberIds.count)
@ -1042,7 +842,7 @@ final class ChatModel: ObservableObject {
} }
func dismissConnReqView(_ id: String) { func dismissConnReqView(_ id: String) {
if id == showingInvitation?.pcc.id { if id == showingInvitation?.connId {
markShowingInvitationUsed() markShowingInvitationUsed()
dismissAllSheets() dismissAllSheets()
} }
@ -1054,11 +854,7 @@ final class ChatModel: ObservableObject {
func removeChat(_ id: String) { func removeChat(_ id: String) {
withAnimation { withAnimation {
if let i = getChatIndex(id) { chats.removeAll(where: { $0.id == id })
let removed = chats.remove(at: i)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
removeWallpaperFilesFromChat(removed)
}
} }
} }
@ -1096,27 +892,10 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember) _ = upsertGroupMember(groupInfo, updatedMember)
} }
} }
func removeWallpaperFilesFromChat(_ chat: Chat) {
if case let .direct(contact) = chat.chatInfo {
removeWallpaperFilesFromTheme(contact.uiThemes)
} else if case let .group(groupInfo) = chat.chatInfo {
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
}
func removeWallpaperFilesFromAllChats(_ user: User) {
// Currently, only removing everything from currently active user is supported. Inactive users are TODO
if user.userId == currentUser?.userId {
chats.forEach {
removeWallpaperFilesFromChat($0)
}
}
}
} }
struct ShowingInvitation { struct ShowingInvitation {
var pcc: PendingContactConnection var connId: String
var connChatUsed: Bool var connChatUsed: Bool
} }
@ -1152,14 +931,27 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
) )
} }
var unreadTag: Bool { var userCanSend: Bool {
switch chatInfo.chatSettings?.enableNtfs { switch chatInfo {
case .all: chatStats.unreadChat || chatStats.unreadCount > 0 case .direct: return true
case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0 case let .group(groupInfo):
default: chatStats.unreadChat let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member
case .local:
return true
default: return false
} }
} }
var userIsObserver: Bool {
switch chatInfo {
case let .group(groupInfo):
let m = groupInfo.membership
return m.memberActive && m.memberRole == .observer
default: return false
}
}
var id: ChatId { get { chatInfo.id } } var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }

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),
@ -75,12 +58,10 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
} }
} else { } else {
if let chatId = content.targetContentIdentifier { if let chatId = content.targetContentIdentifier {
self.navigatingToChat = true ItemsModel.shared.loadOpenChat(chatId)
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 +76,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 +185,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 +210,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) {

File diff suppressed because it is too large Load diff

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
@ -87,27 +82,12 @@ 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 { Task {
chatModel.notificationResponse = nil await updateChats()
NtfManager.shared.processNotificationResponse(ntfResponse) if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
} await 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
} }
} }
} }
@ -157,8 +137,7 @@ struct SimpleXApp: App {
let chats = try await apiGetChatsAsync() let chats = try await apiGetChatsAsync()
await MainActor.run { chatModel.updateChats(chats) } await MainActor.run { chatModel.updateChats(chats) }
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id), let chat = chatModel.getChat(id) {
!NtfManager.shared.navigatingToChat {
Task { await loadChat(chat: chat, clearItems: false) } Task { await loadChat(chat: chat, clearItems: false) }
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {

View file

@ -243,7 +243,7 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil ChatReceiver.shared.messagesChannel = nil
return return
} }
if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg, if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
chatItems.contains(where: { ci in chatItems.contains(where: { ci in
ci.chatInfo.id == call.contact.id && ci.chatInfo.id == call.contact.id &&
ci.chatItem.content.isSndCall && ci.chatItem.content.isSndCall &&
@ -361,7 +361,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 +390,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,13 +401,15 @@ 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", padding: 10) {
if let uuid = call.callUUID { if let uuid = call.callUUID {
cc.endCall(callUUID: uuid) cc.endCall(callUUID: uuid)
} else { } else {
cc.endCall(call: call) {} cc.endCall(call: call) {}
} }
} }
.background(.red)
.clipShape(.circle)
} }
private func toggleMicButton() -> some View { private func toggleMicButton() -> some View {
@ -467,7 +469,7 @@ struct ActiveCallOverlay: View {
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true) .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", padding: 12) {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { if await WebRTCClient.isAuthorized(for: .video) {
@ -477,11 +479,13 @@ struct ActiveCallOverlay: View {
} }
} }
private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) callButton(imageName, padding: padding, perform)
.background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
.clipShape(.circle)
} }
private func audioDevicePickerButton() -> some View { @ViewBuilder private func audioDevicePickerButton() -> some View {
AudioDevicePicker() AudioDevicePicker()
.opacity(0.8) .opacity(0.8)
.scaleEffect(2) .scaleEffect(2)
@ -491,7 +495,7 @@ struct ActiveCallOverlay: View {
.clipShape(.circle) .clipShape(.circle)
} }
private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { private func callButton(_ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button { Button {
perform() perform()
} label: { } label: {
@ -500,10 +504,8 @@ struct ActiveCallOverlay: View {
.scaledToFit() .scaledToFit()
.padding(padding) .padding(padding)
.frame(width: 60, height: 60) .frame(width: 60, height: 60)
.background(background)
} }
.foregroundColor(whiteColorWithAlpha) .foregroundColor(whiteColorWithAlpha)
.clipShape(.circle)
} }
private var whiteColorWithAlpha: Color { private var whiteColorWithAlpha: Color {

View file

@ -357,9 +357,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
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: callUUID) {
logger.debug("CallController.startCall: call started") if callManager.startOutgoingCall(callUUID: callUUID) {
} else { logger.debug("CallController.startCall: call started")
logger.error("CallController.startCall: no active call") } else {
logger.error("CallController.startCall: no active call")
}
} }
} }

View file

@ -21,22 +21,16 @@ struct CallViewRemote: UIViewRepresentable {
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame) let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
remoteCameraRenderer.videoContentMode = contentMode remoteCameraRenderer.videoContentMode = contentMode
remoteCameraRenderer.tag = 0 remoteCameraRenderer.tag = 0
let screenVideo = call.peerMediaSources.screenVideo
let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame) let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
remoteScreenRenderer.videoContentMode = contentMode remoteScreenRenderer.videoContentMode = contentMode
remoteScreenRenderer.tag = 1 remoteScreenRenderer.tag = 1
remoteScreenRenderer.alpha = screenVideo ? 1 : 0 remoteScreenRenderer.alpha = call.peerMediaSources.screenVideo ? 1 : 0
context.coordinator.cameraRenderer = remoteCameraRenderer context.coordinator.cameraRenderer = remoteCameraRenderer
context.coordinator.screenRenderer = remoteScreenRenderer context.coordinator.screenRenderer = remoteScreenRenderer
client.addRemoteCameraRenderer(remoteCameraRenderer) client.addRemoteCameraRenderer(remoteCameraRenderer)
client.addRemoteScreenRenderer(remoteScreenRenderer) client.addRemoteScreenRenderer(remoteScreenRenderer)
if screenVideo { addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
} else {
addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
}
if AVPictureInPictureController.isPictureInPictureSupported() { if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context) makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)

View file

@ -306,7 +306,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) { func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) {
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)") // logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)")
Task { Task {
var lastBytesReceived: Int64 = 0 // for some reason even for disabled tracks one packet arrives (seeing this on screenVideo track)
var lastPacketsReceived = 1
// muted initially // muted initially
var mutedSeconds = 4 var mutedSeconds = 4
while let call = self.activeCall, transceiver.receiver.track?.readyState == .live { while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
@ -314,8 +315,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"}) let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
if let stat { if let stat {
//logger.debug("Stat \(stat.debugDescription)") //logger.debug("Stat \(stat.debugDescription)")
let bytes = stat.values["bytesReceived"] as! Int64 let packets = stat.values["packetsReceived"] as! Int
if bytes <= lastBytesReceived { if packets <= lastPacketsReceived {
mutedSeconds += 1 mutedSeconds += 1
if mutedSeconds == 3 { if mutedSeconds == 3 {
await MainActor.run { await MainActor.run {
@ -328,7 +329,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
self.onMediaMuteUnmute(transceiver.mid, false) self.onMediaMuteUnmute(transceiver.mid, false)
} }
} }
lastBytesReceived = bytes lastPacketsReceived = packets
mutedSeconds = 0 mutedSeconds = 0
} }
} }

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)

View file

@ -7,7 +7,7 @@
// //
import SwiftUI import SwiftUI
@preconcurrency import SimpleXChat import SimpleXChat
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View { func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack { HStack {
@ -96,8 +96,6 @@ struct ChatInfoView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State var contact: Contact @State var contact: Contact
@State var localAlias: String @State var localAlias: String
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
var onSearch: () -> Void var onSearch: () -> Void
@State private var connectionStats: ConnectionStats? = nil @State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil @State private var customUserProfile: Profile? = nil
@ -109,7 +107,6 @@ struct ChatInfoView: View {
@State private var showConnectContactViaAddressDialog = false @State private var showConnectContactViaAddressDialog = false
@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
enum ChatInfoViewAlert: Identifiable { enum ChatInfoViewAlert: Identifiable {
@ -138,50 +135,50 @@ struct ChatInfoView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { List {
List { contactInfoHeader()
contactInfoHeader()
.listRowBackground(Color.clear)
.contentShape(Rectangle())
.onTapGesture {
aliasTextFieldFocused = false
}
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
if let nextNtfMode = chat.chatInfo.nextNtfMode {
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
}
}
.padding(.trailing)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .contentShape(Rectangle())
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) .onTapGesture {
aliasTextFieldFocused = false
if let customUserProfile = customUserProfile { }
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
HStack { Group {
Text("Your random profile") localAliasTextEdit()
Spacer() }
Text(customUserProfile.chatViewName) .listRowBackground(Color.clear)
.foregroundStyle(.indigo) .listRowSeparator(.hidden)
} .padding(.bottom, 18)
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
.padding(.trailing)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
if let customUserProfile = customUserProfile {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Your random profile")
Spacer()
Text(customUserProfile.chatViewName)
.foregroundStyle(.indigo)
} }
} }
}
Section {
Section {
Group {
if let code = connectionCode { verifyCodeButton(code) } if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton() contactPreferencesButton()
sendReceiptsOption() sendReceiptsOption()
@ -192,109 +189,97 @@ struct ChatInfoView: View {
// } else if developerTools { // } else if developerTools {
// synchronizeConnectionButtonForce() // synchronizeConnectionButtonForce()
// } // }
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
.disabled(!contact.ready || !contact.active) .disabled(!contact.ready || !contact.active)
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
if let conn = contact.activeConn {
Section { Section {
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
}
}
if let contactLink = contact.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: { } footer: {
Text("Delete chat messages from your device.") Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
.foregroundColor(theme.colors.secondary)
} }
}
if let conn = contact.activeConn {
Section { if contact.ready && contact.active {
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
} networkStatusRow()
} .onTapGesture {
alert = .networkStatusAlert
if let contactLink = contact.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
} }
} header: { if let connStats = connectionStats {
Text("Address") Button("Change receiving address") {
.foregroundColor(theme.colors.secondary) alert = .switchAddressAlert
} footer: { }
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") .disabled(
.foregroundColor(theme.colors.secondary) connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
} || connStats.ratchetSyncSendProhibited
} )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
if contact.ready && contact.active { Button("Abort changing address") {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { alert = .abortSwitchAddressAlert
networkStatusRow()
.onTapGesture {
alert = .networkStatusAlert
}
if let connStats = connectionStats {
Button("Change receiving address") {
alert = .switchAddressAlert
} }
.disabled( .disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
}
Section {
clearChatButton()
deleteContactButton()
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
if progressIndicator { Section {
ProgressView().scaleEffect(2) clearChatButton()
deleteContactButton()
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
}
}
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { .onAppear {
@ -303,6 +288,7 @@ struct ChatInfoView: View {
} }
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task { Task {
do { do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
@ -326,15 +312,7 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert() case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
return syncConnectionForceAlert({
Task {
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
})
case let .queueInfo(info): return queueInfoAlert(info) case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return mkAlert(title: title, message: error)
@ -344,21 +322,11 @@ struct ChatInfoView: View {
.sheet(item: $sheet) { .sheet(item: $sheet) {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
$0.content $0.content
.presentationDetents([.fraction($0.fraction)]) .presentationDetents([.fraction(0.4)])
} else { } else {
$0.content $0.content
} }
} }
.onDisappear {
if currentFeaturesAllowed != featuresAllowed {
showAlert(
title: NSLocalizedString("Save preferences?", comment: "alert title"),
buttonTitle: NSLocalizedString("Save and notify contact", comment: "alert button"),
buttonAction: { savePreferences() },
cancelButton: true
)
}
}
} }
private func contactInfoHeader() -> some View { private func contactInfoHeader() -> some View {
@ -371,7 +339,7 @@ struct ChatInfoView: 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(contact.profile.displayName) + Text(contact.profile.displayName)
.font(.largeTitle) .font(.largeTitle)
) )
@ -434,13 +402,13 @@ struct ChatInfoView: View {
.disabled(!contact.ready || chat.chatItems.isEmpty) .disabled(!contact.ready || chat.chatItems.isEmpty)
} }
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { private func muteButton(width: CGFloat) -> some View {
return InfoViewButton( InfoViewButton(
image: nextNtfMode.iconFilled, image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: "\(nextNtfMode.text(mentions: false))", title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width width: width
) { ) {
toggleNotifications(chat, enableNtfs: nextNtfMode) toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} }
.disabled(!contact.ready || !contact.active) .disabled(!contact.ready || !contact.active)
} }
@ -479,9 +447,8 @@ struct ChatInfoView: View {
NavigationLink { NavigationLink {
ContactPreferencesView( ContactPreferencesView(
contact: $contact, contact: $contact,
featuresAllowed: $featuresAllowed, featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
currentFeaturesAllowed: $currentFeaturesAllowed, currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
savePreferences: savePreferences
) )
.navigationBarTitle("Contact preferences") .navigationBarTitle("Contact preferences")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
@ -510,15 +477,10 @@ struct ChatInfoView: View {
chatSettings.sendRcpts = sendReceipts.bool() chatSettings.sendRcpts = sendReceipts.bool()
updateChatSettings(chat, chatSettings: chatSettings) updateChatSettings(chat, chatSettings: chatSettings)
} }
private func synchronizeConnectionButton() -> some View { private func synchronizeConnectionButton() -> some View {
Button { Button {
Task { syncContactConnection(force: false)
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
} label: { } label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath") Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange) .foregroundColor(.orange)
@ -637,113 +599,29 @@ struct ChatInfoView: View {
} }
} }
private func savePreferences() { private func syncContactConnection(force: Bool) {
Task { Task {
do { do {
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed) let stats = try apiSyncContactRatchet(contact.apiId, force)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) { connectionStats = stats
await MainActor.run { await MainActor.run {
contact = toContact chatModel.updateContactConnectionStats(contact, stats)
chatModel.updateContact(toContact) dismiss()
currentFeaturesAllowed = featuresAllowed
}
} }
} catch { } catch let error {
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))") logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
} let a = getErrorAlert(error, "Error synchronizing connection")
} await MainActor.run {
} alert = .error(title: a.title, error: a.message)
}
struct ChatTTLOption: View {
@ObservedObject var chat: Chat
@Binding var progressIndicator: Bool
@State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0))
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
var body: some View {
Picker("Delete messages after", selection: $chatItemTTL) {
ForEach(ChatItemTTL.values) { ttl in
Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl))
}
let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL)
Text(defaultTTL.text).tag(defaultTTL)
if case .chat(let ttl) = chatItemTTL, case .seconds = ttl {
Text(ttl.deleteAfterText).tag(chatItemTTL)
}
}
.disabled(progressIndicator)
.frame(height: 36)
.onChange(of: chatItemTTL) { ttl in
if ttl == currentChatItemTTL { return }
setChatTTL(
ttl,
hasPreviousTTL: !currentChatItemTTL.neverExpires,
onCancel: { chatItemTTL = currentChatItemTTL }
) {
progressIndicator = true
Task {
let m = ChatModel.shared
do {
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
await loadChat(chat: chat, clearItems: true)
await MainActor.run {
progressIndicator = false
currentChatItemTTL = chatItemTTL
if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id,
let chat = m.getChat(chat.id) {
chat.chatItems = []
m.replaceChat(chat.id, chat)
}
}
}
catch let error {
logger.error("setChatTTL error \(responseError(error))")
await loadChat(chat: chat, clearItems: true)
await MainActor.run {
chatItemTTL = currentChatItemTTL
progressIndicator = false
}
}
} }
} }
} }
.onAppear {
let sm = ChatModel.shared
let ttl = chat.chatInfo.ttl(sm.chatItemTTL)
chatItemTTL = ttl
currentChatItemTTL = ttl
}
}
}
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats)
}
return stats
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
showAlert(
SomeAlert(
alert: mkAlert(title: a.title, message: a.message),
id: "syncContactConnection error"
)
)
}
return nil
} }
} }
struct AudioCallButton: View { struct AudioCallButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat var width: CGFloat
var showAlert: (SomeAlert) -> Void var showAlert: (SomeAlert) -> Void
@ -751,7 +629,6 @@ struct AudioCallButton: View {
CallButton( CallButton(
chat: chat, chat: chat,
contact: contact, contact: contact,
connectionStats: $connectionStats,
image: "phone.fill", image: "phone.fill",
title: "call", title: "call",
mediaType: .audio, mediaType: .audio,
@ -764,7 +641,6 @@ struct AudioCallButton: View {
struct VideoButton: View { struct VideoButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat var width: CGFloat
var showAlert: (SomeAlert) -> Void var showAlert: (SomeAlert) -> Void
@ -772,7 +648,6 @@ struct VideoButton: View {
CallButton( CallButton(
chat: chat, chat: chat,
contact: contact, contact: contact,
connectionStats: $connectionStats,
image: "video.fill", image: "video.fill",
title: "video", title: "video",
mediaType: .video, mediaType: .video,
@ -785,7 +660,6 @@ struct VideoButton: View {
private struct CallButton: View { private struct CallButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var image: String var image: String
var title: LocalizedStringKey var title: LocalizedStringKey
var mediaType: CallMediaType var mediaType: CallMediaType
@ -797,40 +671,12 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) { InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall { if canCall {
if let connStats = connectionStats { if CallController.useCallKit() {
if connStats.ratchetSyncState == .ok { CallController.shared.startCall(contact, mediaType)
if CallController.useCallKit() { } else {
CallController.shared.startCall(contact, mediaType) // When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
} else { dismissAllSheets(animated: true) {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first CallController.shared.startCall(contact, mediaType)
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if connStats.ratchetSyncAllowed {
showAlert(SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
connectionStats = stats
}
}
},
secondaryButton: .cancel()
),
id: "can't call contact, fix connection"
))
} else {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Encryption renegotiation in progress."
),
id: "can't call contact, encryption renegotiation in progress"
))
} }
} }
} else if contact.nextSendGrpInv { } else if contact.nextSendGrpInv {
@ -1129,33 +975,6 @@ func deleteContactDialog(
} }
} }
func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
let title = if ttl.neverExpires {
NSLocalizedString("Disable automatic message deletion?", comment: "alert title")
} else if ttl.usingDefault || hasPreviousTTL {
NSLocalizedString("Change automatic message deletion?", comment: "alert title")
} else {
NSLocalizedString("Enable automatic message deletion?", comment: "alert title")
}
let message = if ttl.neverExpires {
NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message")
} else {
NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message")
}
showAlert(title, message: message) {
[
UIAlertAction(
title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"),
style: .destructive,
handler: { _ in onConfirm() }
),
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() })
]
}
}
private func deleteContactOrConversationDialog( private func deleteContactOrConversationDialog(
_ chat: Chat, _ chat: Chat,
_ contact: Contact, _ contact: Contact,
@ -1354,9 +1173,7 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData, contact: Contact.sampleData,
localAlias: "", localAlias: "",
featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), onSearch: {}
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
onSearch: {}
) )
} }
} }

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,11 @@ 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 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
@ -106,9 +106,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

@ -19,42 +19,42 @@ struct CIFileView: View {
var body: some View { var body: some View {
if smallViewSize != nil { if smallViewSize != nil {
fileIndicator() fileIndicator()
.simultaneousGesture(TapGesture().onEnded(fileAction)) .onTapGesture(perform: fileAction)
} else { } else {
let metaReserve = edited let metaReserve = edited
? " " ? " "
: " " : " "
HStack(alignment: .bottom, spacing: 6) { Button(action: fileAction) {
fileIndicator() HStack(alignment: .bottom, spacing: 6) {
.padding(.top, 5) fileIndicator()
.padding(.bottom, 3) .padding(.top, 5)
if let file = file { .padding(.bottom, 3)
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) if let file = file {
VStack(alignment: .leading, spacing: 2) { let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
Text(file.fileName) VStack(alignment: .leading, spacing: 2) {
.lineLimit(1) Text(file.fileName)
.multilineTextAlignment(.leading) .lineLimit(1)
.foregroundColor(theme.colors.onBackground) .multilineTextAlignment(.leading)
Text(prettyFileSize + metaReserve) .foregroundColor(theme.colors.onBackground)
.font(.caption) Text(prettyFileSize + metaReserve)
.lineLimit(1) .font(.caption)
.multilineTextAlignment(.leading) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
} }
} else {
Text(metaReserve)
} }
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive) .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 +118,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 +140,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
} }
} }
@ -256,26 +268,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 +285,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

@ -45,7 +45,7 @@ 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, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
@ -53,7 +53,7 @@ struct CIGroupInvitationView: View {
} 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, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
@ -84,12 +84,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,7 +12,6 @@ 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?
@ -26,14 +25,12 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) { if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } Group { if smallView { smallViewImageView(uiImage) } else { 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 .if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred)) view.modifier(PrivacyBlur(blurred: $blurred))
} }
.if(!blurred) { v in .onTapGesture { showFullScreenImage = true }
v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false showFullScreenImage = false
} }
@ -45,7 +42,7 @@ struct CIImageView: View {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred)) imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
} }
} }
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation, .rcvAborted:
@ -72,17 +69,29 @@ 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 { .onDisappear {

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

@ -21,15 +21,15 @@ struct CILinkView: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred)) .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 +37,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

@ -15,7 +15,7 @@ struct CIMetaView: View {
@Environment(\.showTimestamp) var showTimestamp: Bool @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 var invertedMaterial = false
@ -83,7 +83,7 @@ enum MetaColorMode {
? Image("checkmark.wide") ? Image("checkmark.wide")
: Image(systemName: "circlebadge.fill") : Image(systemName: "circlebadge.fill")
).foregroundColor(.clear) ).foregroundColor(.clear)
case .invertedMaterial: textSpace.kerning(13) case .invertedMaterial: Text(" ").kerning(13)
} }
} }
} }
@ -120,7 +120,7 @@ func ciMetaText(
if ttl != chatTTL { if ttl != chatTTL {
r = r + colored(Text(shortTimeText(ttl)), resolved) r = r + colored(Text(shortTimeText(ttl)), resolved)
} }
space = textSpace space = Text(" ")
} }
if showViaProxy, meta.sentViaProxy == true { if showViaProxy, meta.sentViaProxy == true {
appendSpace() appendSpace()
@ -138,12 +138,12 @@ func ciMetaText(
} else if !meta.disappearing { } else if !meta.disappearing {
r = r + colorMode.statusSpacer(meta.itemStatus.sent) r = r + colorMode.statusSpacer(meta.itemStatus.sent)
} }
space = textSpace space = Text(" ")
} }
if let enc = encrypted { if let enc = encrypted {
appendSpace() appendSpace()
r = r + statusIconText(enc ? "lock" : "lock.open", resolved) r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = textSpace space = Text(" ")
} }
if showTimesamp { if showTimesamp {
appendSpace() appendSpace()
@ -152,13 +152,11 @@ func ciMetaText(
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 {
colored(Text(Image(systemName: icon)), color) colored(Text(Image(systemName: icon)), color)
} }
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier // Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
@inline(__always)
private func colored(_ t: Text, _ color: Color?) -> Text { private func colored(_ t: Text, _ color: Color?) -> Text {
if let color { if let color {
t.foregroundColor(color) t.foregroundColor(color)

View file

@ -48,7 +48,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)
} }
@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View {
} }
} }
private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
Group { 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 {
@ -121,11 +121,11 @@ 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, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
} }
@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View {
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)
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -144,14 +144,14 @@ 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, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
.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)
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -161,13 +161,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

@ -47,57 +47,57 @@ struct CIVideoView: View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file, let preview { if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
if let urlDecrypted { smallVideoView(decrypted, file, preview)
if smallView { } else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
smallVideoView(urlDecrypted, file, preview) videoView(player, decrypted, file, preview, duration)
} else if let player { } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
videoView(player, urlDecrypted, file, preview, duration) smallVideoViewEncrypted(file, defaultPreview)
} } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
} else if file.loaded { videoViewEncrypted(file, defaultPreview, duration)
if smallView { } else if let preview, let file {
smallVideoViewEncrypted(file, preview) Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
} else { .onTapGesture {
videoViewEncrypted(file, preview, duration) switch file.fileStatus {
} case .rcvInvitation, .rcvAborted:
} else { receiveFileIfValidSize(file: file, receiveFile: receiveFile)
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } case .rcvAccepted:
.simultaneousGesture(TapGesture().onEnded { switch file.fileProtocol {
switch file.fileStatus { case .xftp:
case .rcvInvitation, .rcvAborted: AlertManager.shared.showAlertMsg(
receiveFileIfValidSize(file: file, receiveFile: receiveFile) title: "Waiting for video",
case .rcvAccepted: message: "Video will be received when your contact completes uploading it."
switch file.fileProtocol { )
case .xftp: case .smp:
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Waiting for video", title: "Waiting for video",
message: "Video will be received when your contact completes uploading it." message: "Video will be received when your contact is online, please wait or check later!"
) )
case .smp: case .local: ()
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 { if !smallView {
durationProgress() durationProgress()
} }
} }
if !blurred, let file, showDownloadButton(file.fileStatus) { if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView || !file.showStatusIconInSmallView { if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
} else if !file.showStatusIconInSmallView {
playPauseIcon("play.fill") playPauseIcon("play.fill")
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}) }
} }
} }
} }
@ -151,26 +151,27 @@ 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 { .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 !blurred {
if !decryptionInProgress { if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash") Button {
.simultaneousGesture(TapGesture().onEnded { decrypt(file: file) {
decrypt(file: file) { if urlDecrypted != nil {
if urlDecrypted != nil { videoPlaying = true
videoPlaying = true player?.play()
player?.play()
}
} }
}) }
.disabled(!canBePlayed) } label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else { } else {
videoDecryptionProgress() videoDecryptionProgress()
} }
@ -193,30 +194,29 @@ struct CIVideoView: View {
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.if(!blurred) { v in .onTapGesture {
v.simultaneousGesture(TapGesture().onEnded { switch player.timeControlStatus {
switch player.timeControlStatus { case .playing:
case .playing: player.pause()
player.pause() videoPlaying = false
videoPlaying = false case .paused:
case .paused: if canBePlayed {
if canBePlayed { showFullScreenPlayer = true
showFullScreenPlayer = true
}
default: ()
} }
}) default: ()
}
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !videoPlaying && !blurred { if !videoPlaying && !blurred {
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()
@ -235,7 +235,7 @@ struct CIVideoView: View {
return ZStack(alignment: .topLeading) { return ZStack(alignment: .topLeading) {
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)
smallViewImageView(preview, file) smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works .onTapGesture {
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
} }
@ -256,7 +256,7 @@ struct CIVideoView: View {
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View { private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) { return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file) smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works .onTapGesture {
showFullScreenPlayer = true showFullScreenPlayer = true
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
@ -354,14 +354,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 +381,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)
} }
} }
@ -429,7 +441,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()

View file

@ -168,14 +168,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 +190,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 {
@ -255,29 +267,59 @@ struct VoiceMessagePlayer: View {
} }
} }
private func playbackButton() -> some View { @ViewBuilder private func playbackButton() -> some View {
let icon = switch playbackState { if sizeMultiplier != 1 {
case .noPlayback: "play.fill" switch playbackState {
case .playing: "pause.fill" case .noPlayback:
case .paused: "play.fill" playPauseIcon("play.fill", theme.colors.primary)
} .onTapGesture {
return playPauseIcon(icon, theme.colors.primary) if let recordingSource = getLoadedFileSource(recordingFile) {
.simultaneousGesture(TapGesture().onEnded { _ in startPlayback(recordingSource)
switch playbackState { }
case .noPlayback: }
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
}
} else {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) { if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource) startPlayback(recordingSource)
} }
case .playing: } label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause() audioPlayer?.pause()
playbackState = .paused playbackState = .paused
notifyStateChange() notifyStateChange()
case .paused: } label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play() audioPlayer?.play()
playbackState = .playing playbackState = .playing
notifyStateChange() notifyStateChange()
} 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 {
@ -299,14 +341,28 @@ struct VoiceMessagePlayer: View {
} }
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary) Group {
.simultaneousGesture(TapGesture().onEnded { if sizeMultiplier != 1 {
Task { playPauseIcon(icon, theme.colors.primary)
if let user = chatModel.currentUser { .onTapGesture {
await receiveFile(user: user, fileId: recordingFile.fileId) Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} }
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
} }
}) }
}
} }
func notifyStateChange() { func notifyStateChange() {
@ -386,7 +442,6 @@ struct VoiceMessagePlayer: View {
} }
} }
@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat { func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935 let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio) return squareSize + squareSize * (1 - squareToCircleRatio)
@ -403,12 +458,10 @@ class VoiceItemState {
self.playbackTime = playbackTime self.playbackTime = playbackTime
} }
@inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String { static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)" "\(chat.id) \(chatItem.id)"
} }
@inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String { static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)" "\(chatInfo.id) \(chatItem.id)"
} }
@ -457,10 +510,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30, duration: 30,
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))
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))
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))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }

View file

@ -92,13 +92,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
@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.")
)) ))
} }
@ -71,7 +71,7 @@ struct CIMsgError: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled) .textSelection(.disabled)
.simultaneousGesture(TapGesture().onEnded(onTap)) .onTapGesture(perform: onTap)
} }
} }

View file

@ -12,12 +12,12 @@ 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)
@ -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,50 @@ 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(\.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 +64,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, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
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 +166,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

@ -41,7 +41,7 @@ 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 {

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) { ItemsModel.shared.loadOpenChat(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 {
@ -556,6 +548,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

@ -14,66 +14,47 @@ extension EnvironmentValues {
static let defaultValue: Bool = true static let defaultValue: Bool = true
} }
struct Revealed: EnvironmentKey {
static let defaultValue: Bool = true
}
struct ContainerBackground: EnvironmentKey {
static let defaultValue: UIColor = .clear
}
var showTimestamp: Bool { var showTimestamp: Bool {
get { self[ShowTimestamp.self] } get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue } 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(\.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
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,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false) allowMenu: Binding<Bool> = .constant(false)
) { ) {
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
} }
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, 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()
} }
@ -102,8 +83,8 @@ 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,
@ -115,9 +96,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 +130,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 +164,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 +177,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 +197,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 +228,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 +238,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 +266,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 +277,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 +288,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 +299,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 +310,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 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()

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 forwardingItems(chatItems: [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,12 +71,10 @@ struct ComposeState {
} else { } else {
self.voiceMessageRecordingState = .noRecording self.voiceMessageRecordingState = .noRecording
} }
self.mentions = editingItem.mentions ?? [:]
} }
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = "" self.message = ""
self.parsedMessage = []
self.preview = .noPreview self.preview = .noPreview
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording self.voiceMessageRecordingState = .noRecording
@ -97,38 +82,20 @@ struct ComposeState {
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
@ -149,31 +116,13 @@ struct ComposeState {
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
} }
} }
@ -226,7 +175,7 @@ struct ComposeState {
} }
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 +193,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 +265,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 +287,8 @@ 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 @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -360,11 +297,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 +324,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 +343,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,19 +367,24 @@ 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."
)
}
} }
} }
} }
@ -459,23 +395,21 @@ struct ComposeView: View {
.ignoresSafeArea(.all, edges: .bottom) .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()
@ -752,27 +686,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 {
@ -802,15 +715,6 @@ struct ComposeView: View {
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
Divider() Divider()
case let .reportedItem(chatItem: reportedItem, _):
ContextItemView(
chat: chat,
contextItems: [reportedItem],
contextIcon: "flag",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
contextIconForeground: Color.red
)
Divider()
} }
} }
@ -826,7 +730,6 @@ 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()
@ -837,14 +740,12 @@ struct ComposeView: View {
// Composed text is send as a reply to the last forwarded item // Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last 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) _ = 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,11 +754,10 @@ 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(media):
// TODO: CHECK THIS
let last = media.count - 1 let last = media.count - 1
var msgs: [ComposedMessage] = [] var msgs: [ComposedMessage] = []
if last >= 0 { if last >= 0 {
@ -882,10 +782,10 @@ struct ComposeView: View {
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)
} }
} }
} }
@ -940,7 +840,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,8 +872,6 @@ 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)
} }
@ -993,28 +891,10 @@ struct ComposeView: View {
return nil return nil
} }
} }
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) 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( await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
live: live, live: live,
ttl: ttl ttl: ttl
).first ).first
@ -1078,8 +958,7 @@ struct ComposeView: View {
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)
@ -1198,9 +1077,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 +1096,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 +1128,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
} }
} }
} }
@ -1273,23 +1149,18 @@ 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

@ -15,7 +15,6 @@ struct ContextItemView: View {
let contextItems: [ChatItem] let contextItems: [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,7 +23,7 @@ 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 let singleItem = contextItems.first, contextItems.count == 1 {
if showSender, let sender = singleItem.memberDisplayName { if showSender, let sender = singleItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -70,10 +69,8 @@ struct ContextItemView: View {
.lineLimit(lines) .lineLimit(lines)
} }
private func contextMsgPreview(_ contextItem: ChatItem) -> some View { private func contextMsgPreview(_ contextItem: ChatItem) -> 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) { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
@ -88,7 +85,7 @@ struct ContextItemView: View {
} }
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 +93,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, contextItems: [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,54 @@ 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.5).opacity(0.7))
} }
.overlay(alignment: .topTrailing, content: {
if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
deleteTextButton()
}
})
.overlay(alignment: .bottomTrailing, content: {
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 4)
} else {
composeActionButtons()
// required for intercepting clicks
.background(.white.opacity(0.000001))
}
})
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in .onChange(of: composeState.inProgress) { inProgress in
if inProgress { if inProgress {
@ -111,8 +105,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 +164,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 +187,7 @@ struct SendMessageView: View {
composeState.endLiveDisabled || composeState.endLiveDisabled ||
disableSendButton disableSendButton
) )
.frame(width: 31, height: 31) .frame(width: 29, height: 29)
.contextMenu{ .contextMenu{
sendButtonContextMenuItems() sendButtonContextMenuItems()
} }
@ -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,15 @@ 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() .resizable()
.frame(width: 20, height: 20) .scaledToFit()
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) .frame(width: 20, height: 20)
.opacity(holdingVMR ? 0.7 : 1) .foregroundColor(theme.colors.primary)
}
.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 +272,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
@ -326,7 +320,7 @@ struct SendMessageView: View {
.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 +348,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 +405,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 +421,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 +431,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 +441,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

@ -78,12 +78,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()
@ -146,12 +141,11 @@ struct AddGroupMembersViewCommon: View {
}() }()
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 +169,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 +231,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

@ -18,10 +18,8 @@ struct GroupChatInfoView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
var onSearch: () -> Void 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 groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false
@ -29,7 +27,6 @@ struct GroupChatInfoView: View {
@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
@ -70,112 +67,94 @@ 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) .padding(.bottom, 18)
localAliasTextEdit() infoActionButtons()
.listRowBackground(Color.clear) .padding(.horizontal)
.listRowSeparator(.hidden) .frame(maxWidth: .infinity)
.padding(.bottom, 18) .frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
infoActionButtons() .listRowSeparator(.hidden)
.padding(.horizontal) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight) Section {
.listRowBackground(Color.clear) if groupInfo.canEdit {
.listRowSeparator(.hidden) editGroupButton()
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) }
if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
Section { addOrEditWelcomeMessage()
if groupInfo.isOwner && groupInfo.businessChat == nil { }
editGroupButton() groupPreferencesButton($groupInfo)
} if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { sendReceiptsOption()
addOrEditWelcomeMessage() } else {
} sendReceiptsOptionDisabled()
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) }
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { NavigationLink {
sendReceiptsOption() ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
} header: {
Text("")
} footer: {
Text("Only group owners can change group preferences.")
.foregroundColor(theme.colors.secondary)
}
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
groupLinkButton()
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { alert = .cantInviteIncognitoAlert }
} else { } 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
@ -214,7 +193,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,37 +208,6 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
private func localAliasTextEdit() -> some View {
TextField("Set chat name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
.submitLabel(.done)
.onChange(of: aliasTextFieldFocused) { focused in
if !focused {
setGroupAlias()
}
}
.onSubmit {
setGroupAlias()
}
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
private func setGroupAlias() {
Task {
do {
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
await MainActor.run {
chatModel.updateGroup(gInfo)
}
}
} catch {
logger.error("setGroupAlias error: \(responseError(error))")
}
}
}
func infoActionButtons() -> some View { func infoActionButtons() -> some View {
GeometryReader { g in GeometryReader { g in
let buttonWidth = g.size.width / 4 let buttonWidth = g.size.width / 4
@ -268,9 +216,7 @@ struct GroupChatInfoView: View {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth) addMembersActionButton(width: buttonWidth)
} }
if let nextNtfMode = chat.chatInfo.nextNtfMode { muteButton(width: buttonWidth)
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
@ -284,9 +230,9 @@ struct GroupChatInfoView: View {
.disabled(!groupInfo.ready || chat.chatItems.isEmpty) .disabled(!groupInfo.ready || chat.chatItems.isEmpty)
} }
private func addMembersActionButton(width: CGFloat) -> some View { @ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
ZStack { if chat.chatInfo.incognito {
if chat.chatInfo.incognito { ZStack {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) { InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true groupLinkNavLinkActive = true
} }
@ -298,7 +244,10 @@ struct GroupChatInfoView: View {
} }
.frame(width: 1, height: 1) .frame(width: 1, height: 1)
.hidden() .hidden()
} else { }
.disabled(!groupInfo.ready)
} else {
ZStack {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true addMembersNavLinkActive = true
} }
@ -311,31 +260,26 @@ struct GroupChatInfoView: View {
.frame(width: 1, height: 1) .frame(width: 1, height: 1)
.hidden() .hidden()
} }
.disabled(!groupInfo.ready)
} }
.disabled(!groupInfo.ready)
} }
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { private func muteButton(width: CGFloat) -> some View {
return InfoViewButton( InfoViewButton(
image: nextNtfMode.iconFilled, image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: "\(nextNtfMode.text(mentions: true))", title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width width: width
) { ) {
toggleNotifications(chat, enableNtfs: nextNtfMode) toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} }
.disabled(!groupInfo.ready) .disabled(!groupInfo.ready)
} }
private func addMembersButton() -> some View { private func addMembersButton() -> some View {
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType { NavigationLink {
case .customer: "Add team members"
case .business: "Add friends"
case .none: "Invite members"
}
return NavigationLink {
addMembersDestinationView() addMembersDestinationView()
} label: { } label: {
Label(label, systemImage: "plus") Label("Invite members", systemImage: "plus")
} }
} }
@ -344,13 +288,16 @@ struct GroupChatInfoView: View {
.onAppear { .onAppear {
searchFocussed = false searchFocussed = false
Task { Task {
await chatModel.loadGroupMembers(groupInfo) let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
} }
} }
} }
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,7 +306,7 @@ 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) MemberProfileImage(member, size: 38)
.padding(.trailing, 2) .padding(.trailing, 2)
// TODO server connection status // TODO server connection status
@ -375,21 +322,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 +347,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 +418,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,6 +426,11 @@ 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() groupLinkDestinationView()
@ -554,12 +487,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 +506,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 +537,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 +556,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 +602,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 +624,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!"),
@ -796,8 +663,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
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: {}, onSearch: {}
localAlias: ""
) )
} }
} }

View file

@ -10,14 +10,12 @@ 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
@ -71,10 +69,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 +93,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 {
@ -164,8 +158,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?
@ -99,149 +94,128 @@ struct GroupMemberInfoView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if connectionLoaded { if member.memberActive {
Section {
if member.memberActive { if let code = connectionCode { verifyCodeButton(code) }
Section { if let connStats = connectionStats,
if let code = connectionCode { verifyCodeButton(code) } connStats.ratchetSyncAllowed {
if let connStats = connectionStats, synchronizeConnectionButton()
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
// } 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))")
} }
} }
} }
@ -277,11 +251,6 @@ 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))
} }
@ -289,15 +258,15 @@ struct GroupMemberInfoView: View {
GeometryReader { g in GeometryReader { g in
let buttonWidth = g.size.width / 4 let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let chat = knownContactChat, let contact = knownContact { if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat, width: buttonWidth) knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId { if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth) newDirectChatButton(contactId, width: buttonWidth)
} else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton(member, width: buttonWidth) createMemberContactButton(width: buttonWidth)
} }
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
} }
@ -327,15 +296,10 @@ struct GroupMemberInfoView: View {
} }
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) { 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 = .someAlert(alert: SomeAlert(
alert: mkAlert( alert: mkAlert(
title: title, title: title,
message: messageLabel message: "Direct messages between members are prohibited in this group."
), ),
id: "can't message member, direct messages prohibited" id: "can't message member, direct messages prohibited"
)) ))
@ -366,75 +330,40 @@ struct GroupMemberInfoView: View {
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { InfoViewButton(image: "message.fill", title: "message", width: width) {
Task { Task {
ItemsModel.shared.loadOpenChat("@\(contactId)") { do {
dismissAllSheets(animated: true) let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} }
} }
} }
func createMemberContactButton(_ member: GroupMember, width: CGFloat) -> some View { func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton( InfoViewButton(image: "message.fill", title: "message", width: width) {
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 ItemsModel.shared.loadOpenChat(memberContact.id) {
) { dismissAllSheets(animated: true)
if member.sendMsgEnabled {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
} }
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
} }
}
} else if let connStats = connectionStats {
if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
syncMemberConnection(force: false)
},
secondaryButton: .cancel()
),
id: "can't message member, fix connection"
))
} else if connStats.ratchetSyncInProgress {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Encryption renegotiation in progress."
),
id: "can't message member, encryption renegotiation in progress"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Connection not ready."
),
id: "can't message member, connection not ready"
))
} }
} }
} }
@ -450,7 +379,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 +528,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 +553,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)
} }
@ -804,14 +716,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 +730,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

@ -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,371 @@
//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
}
} else {
controller.update(items: items)
}
}
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: cellReuseId
)
} else {
tableView.register(
HostingCell<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<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
)
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
}
}
.store(in: &bag)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
CGPoint(
x: tableView.contentOffset.x,
y: tableView.contentOffset.y + tableView.bounds.height
),
animated: true
)
Task { representer.scrollState = .atDestination }
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination }
}
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
// Sets content offset on initial load
if itemCount == 0 {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
itemCount = items.count
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
}
func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs
} else {
nil
}
let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id
} else {
nil
}
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
}
return nil
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) {
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<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
}
}
}
typealias ListState = (
scrollOffset: Double,
topItemDate: Date?,
bottomItemId: ChatItem.ID?
)
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(ChatItem.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
}

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

@ -30,22 +30,18 @@ struct SelectedItemsBottomToolbar: View {
var chatInfo: ChatInfo var chatInfo: ChatInfo
// Bool - delete for everyone is possible // Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void var deleteItems: (Bool) -> Void
var archiveItems: () -> Void
var moderateItems: () -> Void var moderateItems: () -> Void
//var shareItems: () -> Void //var shareItems: () -> Void
var forwardItems: () -> Void var forwardItems: () -> Void
@State var deleteEnabled: Bool = false @State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false
@State var canArchiveReports: Bool = false
@State var canModerate: Bool = false @State var canModerate: Bool = false
@State var moderateEnabled: Bool = false @State var moderateEnabled: Bool = false
@State var forwardEnabled: Bool = false @State var forwardEnabled: Bool = false
@State var deleteCountProhibited = false @State var allButtonsDisabled = false
@State var forwardCountProhibited = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -53,19 +49,15 @@ struct SelectedItemsBottomToolbar: View {
HStack(alignment: .center) { HStack(alignment: .center) {
Button { Button {
if canArchiveReports { deleteItems(deleteForEveryoneEnabled)
archiveItems()
} else {
deleteItems(deleteForEveryoneEnabled)
}
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red) .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
} }
.disabled(!deleteEnabled || deleteCountProhibited) .disabled(!deleteEnabled || allButtonsDisabled)
Spacer() Spacer()
Button { Button {
@ -75,9 +67,9 @@ struct SelectedItemsBottomToolbar: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red) .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
} }
.disabled(!moderateEnabled || deleteCountProhibited) .disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0) .opacity(canModerate ? 1 : 0)
Spacer() Spacer()
@ -88,9 +80,9 @@ struct SelectedItemsBottomToolbar: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary) .foregroundColor(!forwardEnabled || allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
} }
.disabled(!forwardEnabled || forwardCountProhibited) .disabled(!forwardEnabled || allButtonsDisabled)
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12) .padding([.leading, .trailing], 12)
@ -113,28 +105,21 @@ struct SelectedItemsBottomToolbar: View {
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) { private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0 let count = selectedItems?.count ?? 0
deleteCountProhibited = count == 0 || count > 200 allButtonsDisabled = count == 0 || count > 20
forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo) canModerate = possibleToModerate(chatInfo)
let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo {
info
} else {
nil
}
if let selected = selectedItems { if let selected = selectedItems {
let me: Bool let me: Bool
let onlyOwnGroupItems: Bool let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) { if selected.contains(ci.id) {
var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport dee = dee && ci.meta.deletable && !ci.localNote
ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
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 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) return (de, dee, me, onlyOwnGroupItems, fe, sel)
} else { } else {
return r return r
} }

View file

@ -42,8 +42,7 @@ struct ChatHelp: View {
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)

View file

@ -43,11 +43,9 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
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 userFont: DynamicTypeSize @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false @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
@ -87,14 +85,13 @@ 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 && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frameCompat(height: dynamicRowHeight) .frame(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
deleteContactDialog( deleteContactDialog(
@ -121,14 +118,12 @@ struct ChatListNavLink: View {
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()
} }
@ -146,13 +141,15 @@ struct ChatListNavLink: View {
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight)
} }
} }
.alert(item: $alert) { $0.alert } .alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) { .sheet(item: $sheet) {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
$0.content $0.content
.presentationDetents([.fraction($0.fraction)]) .presentationDetents([.fraction(0.4)])
} else { } else {
$0.content $0.content
} }
@ -163,7 +160,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: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
if groupInfo.canDelete { if groupInfo.canDelete {
@ -183,12 +180,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: dynamicRowHeight)
.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)
} }
@ -203,54 +199,34 @@ struct ChatListNavLink: View {
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: 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 {
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, chatId: 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: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
} }
@ -311,22 +287,14 @@ struct ChatListNavLink: View {
} }
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { @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 { Button {
AlertManager.shared.showAlert(archiveAllReportsAlert()) toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: { } label: {
SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI) if chat.chatInfo.ntfsEnabled {
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
} else {
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
}
} }
} }
@ -338,72 +306,7 @@ struct ChatListNavLink: View {
} }
.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())
@ -433,7 +336,6 @@ struct ChatListNavLink: View {
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) }
@ -452,6 +354,7 @@ struct ChatListNavLink: View {
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
@ -463,7 +366,6 @@ 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
@ -481,6 +383,7 @@ struct ChatListNavLink: View {
} }
.tint(theme.colors.primary) .tint(theme.colors.primary)
} }
.frame(height: dynamicRowHeight)
.appSheet(isPresented: $showContactConnectionInfo) { .appSheet(isPresented: $showContactConnectionInfo) {
Group { Group {
if case let .contactConnection(contactConnection) = chat.chatInfo { if case let .contactConnection(contactConnection) = chat.chatInfo {
@ -501,9 +404,8 @@ struct ChatListNavLink: View {
} }
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 +414,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 +441,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) }
}, },
@ -579,14 +458,14 @@ struct ChatListNavLink: View {
) )
} }
private func invalidJSONPreview(_ json: Data?) -> some View { 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: dynamicRowHeight)
.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)
} }
} }
@ -595,27 +474,8 @@ struct ChatListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) { ItemsModel.shared.loadOpenChat(contact.id)
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
}
}
}
}
extension View {
@inline(__always)
@ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View {
if #available(iOS 16, *) {
self.frame(height: height)
} else {
VStack(spacing: 0) {
Divider()
.padding(.leading, 16)
self
.frame(height: height)
.padding(.horizontal, 8)
.padding(.vertical, 8)
} }
} }
} }
@ -707,7 +567,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 +582,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))
} }

View file

@ -31,50 +31,9 @@ enum UserPickerSheet: Identifiable {
} }
} }
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case groupReports = 0
case favorites = 1
case contacts = 2
case groups = 3
case business = 4
case notes = 5
var id: Int { rawValue }
var сollapse: Bool {
self != .groupReports
}
}
enum ActiveFilter: Identifiable, Equatable {
case presetTag(PresetTag)
case userTag(ChatTag)
case unread
var id: String {
switch self {
case let .presetTag(tag): "preset \(tag.id)"
case let .userTag(tag): "user \(tag.chatTagId)"
case .unread: "unread"
}
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
struct ServerSettings {
public var currUserServers: [UserOperatorServers]
public var userServers: [UserOperatorServers]
public var serverErrors: [UserServersError]
}
struct UserPickerSheetView: View { struct UserPickerSheetView: View {
let sheet: UserPickerSheet let sheet: UserPickerSheet
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@StateObject private var ss = SaveableSettings()
@State private var loaded = false @State private var loaded = false
var body: some View { var body: some View {
@ -117,21 +76,6 @@ struct UserPickerSheetView: View {
{ loaded = true } { loaded = true }
) )
} }
.onDisappear {
if serversCanBeSaved(
ss.servers.currUserServers,
ss.servers.userServers,
ss.servers.serverErrors
) {
showAlert(
title: NSLocalizedString("Save servers?", comment: "alert title"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
cancelButton: true
)
}
}
.environmentObject(ss)
} }
} }
@ -146,17 +90,12 @@ struct ChatListView: View {
@State private var searchChatFilteredBySimplexLink: String? = nil @State private var searchChatFilteredBySimplexLink: String? = nil
@State private var scrollToSearchBar = false @State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false @State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@State private var viewOnScreen = true
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately) viewBody.scrollDismissesKeyboard(.immediately)
@ -164,7 +103,7 @@ struct ChatListView: View {
viewBody viewBody
} }
} }
private var viewBody: some View { private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) { ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat( NavStackCompat(
@ -182,11 +121,9 @@ struct ChatListView: View {
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet) UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
} }
) )
.appSheet( .sheet(item: $activeUserPickerSheet) {
item: $activeUserPickerSheet, UserPickerSheetView(sheet: $0)
onDismiss: { chatModel.laRequest = nil }, }
content: { UserPickerSheetView(sheet: $0) }
)
.onChange(of: activeUserPickerSheet) { .onChange(of: activeUserPickerSheet) {
if $0 != nil { if $0 != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
@ -194,9 +131,8 @@ struct ChatListView: View {
} }
} }
} }
.environmentObject(chatTagsModel)
} }
private var chatListView: some View { private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial) let tm = ToolbarMaterial.material(toolbarMaterial)
return withToolbar(tm) { return withToolbar(tm) {
@ -206,17 +142,7 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI) .navigationBarHidden(searchMode || oneHandUI)
} }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onAppear { .onDisappear() { activeUserPickerSheet = nil }
if #unavailable(iOS 16.0), !viewOnScreen {
viewOnScreen = true
}
}
.onDisappear {
activeUserPickerSheet = nil
if #unavailable(iOS 16.0) {
viewOnScreen = false
}
}
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"), title: Text("Reconnect servers?"),
@ -241,22 +167,15 @@ struct ChatListView: View {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm) Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
} }
} }
.sheet(item: $sheet) { sheet in
if #available(iOS 16.0, *) {
sheet.content.presentationDetents([.fraction(sheet.fraction)])
} else {
sheet.content
}
}
} }
static var hasHomeIndicator: Bool = { static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first { let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0 window.safeAreaInsets.bottom > 0
} else { false } } else { false }
}() }()
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View { @ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
if oneHandUI { if oneHandUI {
@ -271,19 +190,19 @@ struct ChatListView: View {
} }
} else { } else {
if oneHandUI { if oneHandUI {
content().toolbar { bottomToolbarGroup() } content().toolbar { bottomToolbarGroup }
} else { } else {
content().toolbar { topToolbar } content().toolbar { topToolbar }
} }
} }
} }
@ToolbarContentBuilder var topToolbar: some ToolbarContent { @ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() } ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem } ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
} }
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent { @ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14 let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) { ToolbarItem(placement: .bottomBar) {
@ -298,10 +217,10 @@ struct ChatListView: View {
.onTapGesture { scrollToSearchBar = true } .onTapGesture { scrollToSearchBar = true }
} }
} }
@ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14 let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { ToolbarItemGroup(placement: .bottomBar) {
leadingToolbarItem.padding(.bottom, padding) leadingToolbarItem.padding(.bottom, padding)
Spacer() Spacer()
SubsStatusIndicator().padding(.bottom, padding) SubsStatusIndicator().padding(.bottom, padding)
@ -309,7 +228,7 @@ struct ChatListView: View {
trailingToolbarItem.padding(.bottom, padding) trailingToolbarItem.padding(.bottom, padding)
} }
} }
@ViewBuilder var leadingToolbarItem: some View { @ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
@ -326,7 +245,7 @@ struct ChatListView: View {
userPickerShown = true userPickerShown = true
} }
} }
@ViewBuilder var trailingToolbarItem: some View { @ViewBuilder var trailingToolbarItem: some View {
switch chatModel.chatRunning { switch chatModel.chatRunning {
case .some(true): NewChatMenuButton() case .some(true): NewChatMenuButton()
@ -334,10 +253,10 @@ struct ChatListView: View {
case .none: EmptyView() case .none: EmptyView()
} }
} }
private var chatList: some View { @ViewBuilder private var chatList: some View {
let cs = filteredChats() let cs = filteredChats()
return ZStack { ZStack {
ScrollViewReader { scrollProxy in ScrollViewReader { scrollProxy in
List { List {
if !chatModel.chats.isEmpty { if !chatModel.chats.isEmpty {
@ -346,8 +265,7 @@ struct ChatListView: View {
searchFocussed: $searchFocussed, searchFocussed: $searchFocussed,
searchText: $searchText, searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink, searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink, searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
parentSheet: $sheet
) )
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
@ -356,9 +274,15 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0) .padding(.top, oneHandUI ? 8 : 0)
.id("searchBar") .id("searchBar")
} }
if !oneHandUICardShown {
OneHandUICard()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat, parentSheet: $sheet) ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16) .padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
@ -367,7 +291,13 @@ struct ChatListView: View {
.offset(x: -8) .offset(x: -8)
} else { } else {
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat, parentSheet: $sheet) VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
@ -375,20 +305,6 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
} }
} }
if !oneHandUICardShown {
OneHandUICard()
.padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !addressCreationCardShown {
AddressCreationCard()
.padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
} }
.listStyle(.plain) .listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in .onChange(of: chatModel.chatId) { currentChatId in
@ -409,97 +325,80 @@ struct ChatListView: View {
} }
} }
if cs.isEmpty && !chatModel.chats.isEmpty { if cs.isEmpty && !chatModel.chats.isEmpty {
noChatsView() Text("No filtered chats")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
} }
@ViewBuilder private func noChatsView() -> some View {
if searchString().isEmpty {
switch chatTagsModel.activeFilter {
case .presetTag: Text("No filtered chats") // this should not happen
case let .userTag(tag): Text("No chats in list \(tag.chatTagText)")
case .unread:
Button {
chatTagsModel.activeFilter = nil
} label: {
HStack {
Image(systemName: "line.3.horizontal.decrease")
Text("No unread chats")
}
}
case .none: Text("No chats")
}
} else {
Text("No chats found")
}
}
private func unreadBadge(size: CGFloat = 18) -> some View { private func unreadBadge(size: CGFloat = 18) -> some View {
Circle() Circle()
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
} }
@ViewBuilder private func chatView() -> some View { @ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat) ChatView(chat: chat)
} }
} }
func stopAudioPlayer() { func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:] VoiceItemState.smallView = [:]
} }
private func filteredChats() -> [Chat] { private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink { if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId } return chatModel.chats.filter { $0.id == linkChatId }
} else { } else {
let s = searchString() let s = searchString()
return s == "" return s == "" && !showUnreadAndFavorites
? chatModel.chats.filter { chat in ? chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat) !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
} }
: chatModel.chats.filter { chat in : chatModel.chats.filter { chat in
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
return switch cInfo { switch cInfo {
case let .direct(contact): case let .direct(contact):
!contact.chatDeleted && !chat.chatInfo.contactCard && ( return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
( viewNameContains(cInfo, s) || s == ""
contact.profile.displayName.localizedLowercase.contains(s) || ? filtered(chat)
contact.fullName.localizedLowercase.contains(s) : (viewNameContains(cInfo, s) ||
) contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
) )
case .group: viewNameContains(cInfo, s) case let .group(gInfo):
case .local: viewNameContains(cInfo, s) return s == ""
case .contactRequest: viewNameContains(cInfo, s) ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s) : viewNameContains(cInfo, s)
case .invalidJSON: false case .local:
return s == "" || viewNameContains(cInfo, s)
case .contactRequest:
return s == "" || viewNameContains(cInfo, s)
case let .contactConnection(conn):
return s != "" && conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON:
return false
} }
} }
} }
func filtered(_ chat: Chat) -> Bool { func searchString() -> String {
switch chatTagsModel.activeFilter { searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.unreadTag
case .none: true
}
} }
func filtered(_ chat: Chat) -> Bool {
(chat.chatInfo.chatSettings?.favorite ?? false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s) cInfo.chatViewName.localizedLowercase.contains(s)
} }
} }
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
} }
struct SubsStatusIndicator: View { struct SubsStatusIndicator: View {
@ -538,7 +437,7 @@ struct SubsStatusIndicator: View {
private func startTask() { private func startTask() {
task = Task { task = Task {
while !Task.isCancelled { while !Task.isCancelled {
if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true { if AppChatState.shared.value == .active {
do { do {
let (subs, hasSess) = try await getAgentSubsTotal() let (subs, hasSess) = try await getAgentSubsTotal()
await MainActor.run { await MainActor.run {
@ -563,20 +462,18 @@ struct SubsStatusIndicator: View {
struct ChatListSearchBar: View { struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@Binding var searchMode: Bool @Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool @FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String @Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool @Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String? @Binding var searchChatFilteredBySimplexLink: String?
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var ignoreSearchTextChange = false @State private var ignoreSearchTextChange = false
@State private var alert: PlanAndConnectAlert? @State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet? @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
@ -634,9 +531,6 @@ struct ChatListSearchBar: View {
} }
} }
} }
.onChange(of: chatTagsModel.activeFilter) { _ in
searchText = ""
}
.alert(item: $alert) { a in .alert(item: $alert) { a in
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" }) planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
} }
@ -646,21 +540,16 @@ struct ChatListSearchBar: View {
} }
private func toggleFilterButton() -> some View { private func toggleFilterButton() -> some View {
let showUnread = chatTagsModel.activeFilter == .unread ZStack {
return ZStack {
Color.clear Color.clear
.frame(width: 22, height: 22) .frame(width: 22, height: 22)
Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease") Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary) .foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16) .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture { .onTapGesture {
if chatTagsModel.activeFilter == .unread { showUnreadAndFavorites = !showUnreadAndFavorites
chatTagsModel.activeFilter = nil
} else {
chatTagsModel.activeFilter = .unread
}
} }
} }
} }
@ -678,198 +567,6 @@ struct ChatListSearchBar: View {
} }
} }
struct TagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var parentSheet: SomeSheet<AnyView>?
@Binding var searchText: String
var body: some View {
HStack {
tagsView()
}
}
@ViewBuilder private func tagsView() -> some View {
if chatTagsModel.presetTags.count > 1 {
if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in
if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag)
}
}
}
}
let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let current = tag == selectedTag
let color: Color = current ? .accentColor : .secondary
ZStack {
HStack(spacing: 4) {
if let emoji = tag.chatTagEmoji {
Text(emoji)
} else {
Image(systemName: current ? "tag.fill" : "tag")
.foregroundColor(color)
}
ZStack {
let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? "" : "").font(.footnote)
(Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear)
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary)
}
}
.onTapGesture {
setActiveFilter(filter: .userTag(tag))
}
.onLongPressGesture {
let screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
TagListView(chat: nil)
.modifier(ThemedBackground(grouped: true))
}
)
},
id: "tag list",
fraction: fraction
)
}
}
}
Button {
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
TagListEditor()
}
)
},
id: "tag create"
)
} label: {
if chatTagsModel.userTags.isEmpty {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add list")
}
} else {
Image(systemName: "plus")
}
}
.foregroundColor(.secondary)
}
@ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
Image(systemName: icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag))
}
}
private func expandedPresetTagsFiltersView() -> some View {
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag)
}
}
}
@ViewBuilder private func collapsedTagsFilterView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
Menu {
if chatTagsModel.activeFilter != nil || !searchText.isEmpty {
Button {
chatTagsModel.activeFilter = nil
searchText = ""
} label: {
HStack {
Image(systemName: "list.bullet")
Text("All")
}
}
}
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse {
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
Image(systemName: systemName)
Text(text)
}
}
}
}
} label: {
if let tag = selectedPresetTag, tag.сollapse {
let (systemName, _) = presetTagLabel(tag: tag, active: true)
Image(systemName: systemName)
.foregroundColor(.accentColor)
} else {
Image(systemName: "list.bullet")
.foregroundColor(.secondary)
}
}
.frame(minWidth: 28)
}
private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .groupReports: (active ? "flag.fill" : "flag", "Reports")
case .favorites: (active ? "star.fill" : "star", "Favorites")
case .contacts: (active ? "person.fill" : "person", "Contacts")
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
case .notes: (active ? "folder.fill" : "folder", "Notes")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
} else {
chatTagsModel.activeFilter = nil
}
}
}
func chatStoppedIcon() -> some View { func chatStoppedIcon() -> some View {
Button { Button {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@ -881,35 +578,6 @@ func chatStoppedIcon() -> some View {
} }
} }
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
switch tag {
case .groupReports:
chatStats.reportsCount > 0
case .favorites:
chatInfo.chatSettings?.favorite == true
case .contacts:
switch chatInfo {
case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chatInfo {
case let .group(groupInfo): groupInfo.businessChat == nil
default: false
}
case .business:
chatInfo.groupInfo?.businessChat?.chatType == .business
case .notes:
switch chatInfo {
case .local: true
default: false
}
}
}
struct ChatListView_Previews: PreviewProvider { struct ChatListView_Previews: PreviewProvider {
@State static var userPickerSheet: UserPickerSheet? = .none @State static var userPickerSheet: UserPickerSheet? = .none

View file

@ -27,80 +27,72 @@ struct ChatPreviewView: View {
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: dynamicSize(userFont).profileImageSize)
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) chatPreviewImageOverlayIcon()
chatPreviewImageOverlayIcon() .padding([.bottom, .trailing], 1)
.padding([.bottom, .trailing], 1) }
} .padding(.leading, 4)
.padding(.leading, 4)
let chatTs = if let cItem {
let chatTs = if let cItem { cItem.meta.itemTs
cItem.meta.itemTs } else {
} else { chat.chatInfo.chatTs
chat.chatInfo.chatTs }
} VStack(spacing: 0) {
VStack(spacing: 0) { HStack(alignment: .top) {
HStack(alignment: .top) { chatPreviewTitle()
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()
(formatTimestampText(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) {
let chat = activeContentPreview?.chat ?? chat
if deleting { let ci = activeContentPreview?.ci ?? chat.chatItems.last
ProgressView() let mc = ci?.content.msgContent
.scaleEffect(2) HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatStatusImage()
.padding(.top, dynamicChatInfoSize * 1.44)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Spacer()
} }
.frame(maxHeight: .infinity)
} }
.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 // Stop voice when deleting the chat
@ -143,7 +135,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 +145,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 +160,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,23 +172,20 @@ 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, _ hasFilePreview: 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(userFont <= .xxxLarge ? 2 : 1)
.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, hasFilePreview ? 0 : 8)
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .padding(.trailing, hasFilePreview ? 38 : 36)
.offset(x: hasFilePreview ? -2 : 0) .offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft { if !showChatPreviews && !draft {
@ -212,34 +200,19 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View { @ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat { if s.unreadCount > 0 || s.unreadChat {
let mentionColor = mentionColor(chat) unreadCountText(s.unreadCount)
HStack(alignment: .center, spacing: 2) { .font(userFont <= .xxxLarge ? .caption : .caption2)
if s.unreadMentions > 0 && s.unreadCount > 1 { .foregroundColor(.white)
Text("\(MENTION_START)") .padding(.horizontal, dynamicSize(userFont).unreadPadding)
.font(userFont <= .xxxLarge ? .body : .callout) .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.foregroundColor(mentionColor) .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) .cornerRadius(dynamicSize(userFont).unreadCorner)
.cornerRadius(dynamicSize(userFont).unreadCorner) } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
.padding(.bottom, 1) Image(systemName: "speaker.slash.fill")
}
let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1
(singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount))
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
.frame(height: dynamicChatInfoSize)
} else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all {
let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize
let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary
Image(systemName: ntfMode.iconFilled)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: iconSize, height: iconSize) .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(iconColor) .foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false { } else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.resizable() .resizable()
@ -251,30 +224,20 @@ struct ChatPreviewView: View {
Color.clear.frame(width: 0) 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 +245,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: nil, 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,22 +270,13 @@ 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?, _ hasFilePreview: Bool = false) -> 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, hasFilePreview)
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), hasFilePreview)
chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else { } else {
switch (chat.chatInfo) { switch (chat.chatInfo) {
case let .direct(contact): case let .direct(contact):
@ -343,7 +292,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()
@ -375,16 +323,18 @@ struct ChatPreviewView: View {
.cornerRadius(8) .cornerRadius(8)
} }
.onTapGesture { .onTapGesture {
openBrowserAlert(uri: preview.uri) UIApplication.shared.open(preview.uri)
} }
} }
case let .image(_, image): case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
} }
case let .video(_,image, duration): case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
} }
case let .voice(_, duration): case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) { smallContentPreviewVoice(size: dynamicMediaSize) {
@ -405,7 +355,7 @@ struct ChatPreviewView: View {
: 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,11 +367,11 @@ 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("")
} }
} }
@ -438,8 +388,6 @@ struct ChatPreviewView: View {
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, size: size)
} }
@ -485,14 +433,6 @@ struct ChatPreviewView: View {
} }
} }
func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(.red)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view() view()
.frame(width: size, height: size) .frame(width: size, height: size)

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
@ -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)
@ -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

@ -32,6 +32,7 @@ struct OneHandUICard: View {
.background(theme.appColors.sentMessage) .background(theme.appColors.sentMessage)
.cornerRadius(12) .cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight) .frame(height: dynamicSize(userFont).rowHeight)
.padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) { .alert(isPresented: $showOneHandUIAlert) {
Alert( Alert(
title: Text("Reachable chat toolbar"), title: Text("Reachable chat toolbar"),

View file

@ -245,7 +245,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 +256,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)
} }
@ -318,14 +318,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)
} }
@ -491,6 +491,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 {
@ -587,7 +596,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.")
} }
} }
} }
@ -670,6 +679,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 +721,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.")
} }
} }
} }

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)
)
}
}
}
}
}

View file

@ -33,7 +33,6 @@ struct UserPicker: View {
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse)) .sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0) let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0) let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
let stopped = m.chatRunning != true
VStack(spacing: sectionSpacing) { VStack(spacing: sectionSpacing) {
if let user = m.currentUser { if let user = m.currentUser {
StickyScrollView(resetScroll: $resetScroll) { StickyScrollView(resetScroll: $resetScroll) {
@ -47,14 +46,10 @@ struct UserPicker: View {
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
.modifier(ListRow { activeSheet = .currentProfile }) .modifier(ListRow { activeSheet = .currentProfile })
.clipShape(sectionShape) .clipShape(sectionShape)
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
ForEach(otherUsers) { u in ForEach(otherUsers) { u in
userView(u, size: imageSize) userView(u, size: imageSize)
.frame(maxWidth: sectionWidth * 0.618) .frame(maxWidth: sectionWidth * 0.618)
.fixedSize() .fixedSize()
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
} }
} }
.padding(.horizontal, sectionHorizontalPadding) .padding(.horizontal, sectionHorizontalPadding)
@ -65,10 +60,10 @@ struct UserPicker: View {
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
} }
VStack(spacing: 0) { VStack(spacing: 0) {
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped) openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address)
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped) openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences)
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped) openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles)
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped) openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop)
ZStack(alignment: .trailing) { ZStack(alignment: .trailing) {
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false) openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
@ -124,7 +119,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) { if (u.unreadCount > 0) {
UnreadBadge(userInfo: u).offset(x: 4, y: -4) unreadBadge(u).offset(x: 4, y: -4)
} }
} }
.padding(.trailing, 6) .padding(.trailing, 6)
@ -154,36 +149,29 @@ struct UserPicker: View {
.clipShape(sectionShape) .clipShape(sectionShape)
} }
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View { private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true) -> some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
settingsRow(icon, color: theme.colors.secondary) { settingsRow(icon, color: theme.colors.secondary) {
Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1) Text(title).foregroundColor(.primary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, rowPadding) .padding(.horizontal, rowPadding)
.padding(.vertical, rowVerticalPadding) .padding(.vertical, rowVerticalPadding)
.modifier(ListRow { activeSheet = sheet }) .modifier(ListRow { activeSheet = sheet })
.disabled(disabled)
if showDivider { if showDivider {
Divider().padding(.leading, 52) Divider().padding(.leading, 52)
} }
} }
} }
}
private func unreadBadge(_ u: UserInfo) -> some View {
struct UnreadBadge: View {
var userInfo: UserInfo
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var body: some View {
let size = dynamicSize(userFont).chatInfoSize let size = dynamicSize(userFont).chatInfoSize
unreadCountText(userInfo.unreadCount) return unreadCountText(u.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2) .font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding) .padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size) .frame(minWidth: size, minHeight: size)
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary) .background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner) .cornerRadius(dynamicSize(userFont).unreadCorner)
} }
} }

View file

@ -20,7 +20,7 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false @State private var showContactRequestDialog = false
var body: some View { var body: some View {
let contactType = chatContactType(chat) let contactType = chatContactType(chat: chat)
Group { Group {
switch (chat.chatInfo) { switch (chat.chatInfo) {
@ -140,9 +140,9 @@ struct ContactListNavLink: View {
} }
} }
private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
return ( (
contact.verified == true contact.verified == true
? verifiedIcon + t ? verifiedIcon + t
: t : t
@ -151,7 +151,7 @@ struct ContactListNavLink: View {
} }
private var verifiedIcon: Text { private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
@ -188,7 +188,8 @@ struct ContactListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) { ItemsModel.shared.loadOpenChat(contact.id)
DispatchQueue.main.async {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }

View file

@ -0,0 +1,68 @@
//
// ChatArchiveView.swift
// SimpleXChat
//
// Created by Evgeny on 23/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChatArchiveView: View {
@EnvironmentObject var theme: AppTheme
var archiveName: String
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
@State private var showDeleteAlert = false
var body: some View {
let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName)
let fileTs = chatArchiveTimeDefault.get()
List {
Section {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button {
showShareSheet(items: [fileUrl])
} label: {
Text("Save archive")
}
}
settingsRow("trash", color: theme.colors.secondary) {
Button {
showDeleteAlert = true
} label: {
Text("Delete archive").foregroundColor(.red)
}
}
} header: {
Text("Chat archive")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Created on \(fileTs)")
.foregroundColor(theme.colors.secondary)
}
}
.alert(isPresented: $showDeleteAlert) {
Alert(
title: Text("Delete chat archive?"),
primaryButton: .destructive(Text("Delete")) {
do {
try FileManager.default.removeItem(atPath: fileUrl.path)
chatArchiveName = nil
chatArchiveTime = 0
} catch let error {
logger.error("removeItem error \(String(describing: error))")
}
},
secondaryButton: .cancel()
)
}
}
}
struct ChatArchiveView_Previews: PreviewProvider {
static var previews: some View {
ChatArchiveView(archiveName: "")
}
}

View file

@ -48,8 +48,6 @@ struct DatabaseEncryptionView: View {
@State private var confirmNewKey = "" @State private var confirmNewKey = ""
@State private var currentKeyShown = false @State private var currentKeyShown = false
let stopChatRunBlockStartChat: (Binding<Bool>, @escaping () async throws -> Bool) -> Void
var body: some View { var body: some View {
ZStack { ZStack {
List { List {
@ -136,61 +134,46 @@ struct DatabaseEncryptionView: View {
.onAppear { .onAppear {
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" } if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
} }
.disabled(progressIndicator) .disabled(m.chatRunning != false)
.alert(item: $alert) { item in databaseEncryptionAlert(item) } .alert(item: $alert) { item in databaseEncryptionAlert(item) }
} }
private func encryptDatabaseAsync() async -> Bool {
await MainActor.run {
progressIndicator = true
}
do {
encryptionStartedDefault.set(true)
encryptionStartedAtDefault.set(Date.now)
if !m.chatDbChanged {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
}
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
encryptionStartedDefault.set(false)
initialRandomDBPassphraseGroupDefault.set(false)
if migration {
storeDBPassphraseGroupDefault.set(useKeychain)
}
if useKeychain {
if kcDatabasePassword.set(newKey) {
await resetFormAfterEncryption(true)
await operationEnded(.databaseEncrypted)
} else {
await resetFormAfterEncryption()
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
}
} else {
if migration {
removePassphraseFromKeyChain()
}
await resetFormAfterEncryption()
await operationEnded(.databaseEncrypted)
}
return true
} catch let error {
if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
}
return false
}
}
private func encryptDatabase() { private func encryptDatabase() {
// it will try to stop and start the chat in case of: non-migration && successful encryption. In migration the chat will remain stopped progressIndicator = true
if migration { Task {
Task { do {
await encryptDatabaseAsync() encryptionStartedDefault.set(true)
} encryptionStartedAtDefault.set(Date.now)
} else { if !m.chatDbChanged {
stopChatRunBlockStartChat($progressIndicator) { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
return await encryptDatabaseAsync() }
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
encryptionStartedDefault.set(false)
initialRandomDBPassphraseGroupDefault.set(false)
if migration {
storeDBPassphraseGroupDefault.set(useKeychain)
}
if useKeychain {
if kcDatabasePassword.set(newKey) {
await resetFormAfterEncryption(true)
await operationEnded(.databaseEncrypted)
} else {
await resetFormAfterEncryption()
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
}
} else {
if migration {
removePassphraseFromKeyChain()
}
await resetFormAfterEncryption()
await operationEnded(.databaseEncrypted)
}
} catch let error {
if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
}
} }
} }
} }
@ -388,6 +371,6 @@ func validKey(_ s: String) -> Bool {
struct DatabaseEncryptionView_Previews: PreviewProvider { struct DatabaseEncryptionView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false, stopChatRunBlockStartChat: { _, _ in true }) DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
} }
} }

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct DatabaseErrorView: View { struct DatabaseErrorView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State var status: DBMigrationResult @State var status: DBMigrationResult
@State private var dbKey = "" @State private var dbKey = ""
@State private var storedDBKey = kcDatabasePassword.get() @State private var storedDBKey = kcDatabasePassword.get()
@ -28,40 +27,24 @@ struct DatabaseErrorView: View {
} }
} }
private func databaseErrorView() -> some View { @ViewBuilder private func databaseErrorView() -> some View {
VStack(alignment: .center, spacing: 20) { VStack(alignment: .leading, spacing: 16) {
switch status { switch status {
case let .errorNotADatabase(dbFile): case let .errorNotADatabase(dbFile):
if useKeychain && storedDBKey != nil && storedDBKey != "" { if useKeychain && storedDBKey != nil && storedDBKey != "" {
titleText("Wrong database passphrase") titleText("Wrong database passphrase")
Text("Database passphrase is different from saved in the keychain.") Text("Database passphrase is different from saved in the keychain.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 25)
databaseKeyField(onSubmit: saveAndRunChat) databaseKeyField(onSubmit: saveAndRunChat)
Spacer() saveAndOpenButton()
VStack(spacing: 10) { fileNameText(dbFile)
saveAndOpenButton()
fileNameText(dbFile)
}
} else { } else {
titleText("Encrypted database") titleText("Encrypted database")
Text("Database passphrase is required to open chat.") Text("Database passphrase is required to open chat.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 25)
.padding(.bottom, 5)
if useKeychain { if useKeychain {
databaseKeyField(onSubmit: saveAndRunChat) databaseKeyField(onSubmit: saveAndRunChat)
Spacer()
saveAndOpenButton() saveAndOpenButton()
} else { } else {
databaseKeyField(onSubmit: { runChat() }) databaseKeyField(onSubmit: { runChat() })
Spacer()
openChatButton() openChatButton()
} }
} }
@ -69,105 +52,73 @@ struct DatabaseErrorView: View {
switch migrationError { switch migrationError {
case let .upgrade(upMigrations): case let .upgrade(upMigrations):
titleText("Database upgrade") titleText("Database upgrade")
Button("Upgrade and open chat") { runChat(confirmMigrations: .yesUp) }
fileNameText(dbFile)
migrationsText(upMigrations.map(\.upName)) migrationsText(upMigrations.map(\.upName))
Spacer()
VStack(spacing: 10) {
Button("Upgrade and open chat") {
runChat(confirmMigrations: .yesUp)
}.buttonStyle(OnboardingButtonStyle(isDisabled: false))
fileNameText(dbFile)
}
case let .downgrade(downMigrations): case let .downgrade(downMigrations):
titleText("Database downgrade") titleText("Database downgrade")
Text("Warning: you may lose some data!") Text("Warning: you may lose some data!").bold()
.bold() Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
.padding(.horizontal, 25) fileNameText(dbFile)
.multilineTextAlignment(.center)
migrationsText(downMigrations) migrationsText(downMigrations)
Spacer()
VStack(spacing: 10) {
Button("Downgrade and open chat") {
runChat(confirmMigrations: .yesUpDown)
}.buttonStyle(OnboardingButtonStyle(isDisabled: false))
fileNameText(dbFile)
}
case let .migrationError(mtrError): case let .migrationError(mtrError):
titleText("Incompatible database version") titleText("Incompatible database version")
fileNameText(dbFile, font: .callout) fileNameText(dbFile)
errorView(Text(mtrErrorDescription(mtrError))) Text("Error: ") + Text(mtrErrorDescription(mtrError))
} }
case let .errorSQL(dbFile, migrationSQLError): case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error") titleText("Database error")
fileNameText(dbFile, font: .callout) fileNameText(dbFile)
errorView(Text("Error: \(migrationSQLError)")) Text("Error: \(migrationSQLError)")
case .errorKeychain: case .errorKeychain:
titleText("Keychain error") titleText("Keychain error")
errorView(Text("Cannot access keychain to save database password")) Text("Cannot access keychain to save database password")
case .invalidConfirmation: case .invalidConfirmation:
// this can only happen if incorrect parameter is passed // this can only happen if incorrect parameter is passed
titleText("Invalid migration confirmation") Text(String("Invalid migration confirmation")).font(.title)
errorView()
case let .unknown(json): case let .unknown(json):
titleText("Database error") titleText("Database error")
errorView(Text("Unknown database error: \(json)")) Text("Unknown database error: \(json)")
case .ok: case .ok:
EmptyView() EmptyView()
} }
if showRestoreDbButton { if showRestoreDbButton {
Spacer() Spacer().frame(height: 10)
Text("The attempt to change database passphrase was not completed.") Text("The attempt to change database passphrase was not completed.")
.multilineTextAlignment(.center)
.padding(.horizontal, 25)
.font(.footnote)
restoreDbButton() restoreDbButton()
} }
} }
.padding(.horizontal, 25) .padding()
.padding(.top, 75)
.padding(.bottom, 25)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() } .onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
} }
private func titleText(_ s: LocalizedStringKey) -> some View { private func titleText(_ s: LocalizedStringKey) -> Text {
Text(s).font(.largeTitle).bold().multilineTextAlignment(.center) Text(s).font(.title)
} }
private func fileNameText(_ f: String, font: Font = .caption) -> Text { private func fileNameText(_ f: String) -> Text {
Text("File: \((f as NSString).lastPathComponent)").font(font) Text("File: \((f as NSString).lastPathComponent)")
} }
private func migrationsText(_ ms: [String]) -> some View { private func migrationsText(_ ms: [String]) -> Text {
(Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption)) Text("Migrations: \(ms.joined(separator: ", "))")
.multilineTextAlignment(.center)
.padding(.horizontal, 25)
} }
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
.padding(.vertical, 10)
.padding(.horizontal)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(uiColor: .tertiarySystemFill))
)
} }
private func saveAndOpenButton() -> some View { private func saveAndOpenButton() -> some View {
Button("Save passphrase and open chat") { Button("Save passphrase and open chat") {
saveAndRunChat() saveAndRunChat()
} }
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
} }
private func openChatButton() -> some View { private func openChatButton() -> some View {
Button("Open chat") { Button("Open chat") {
runChat() runChat()
} }
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
} }
private func saveAndRunChat() { private func saveAndRunChat() {
@ -241,9 +192,8 @@ struct DatabaseErrorView: View {
secondaryButton: .cancel() secondaryButton: .cancel()
)) ))
} label: { } label: {
Text("Restore database backup") Text("Restore database backup").foregroundColor(.red)
} }
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
} }
private func restoreDb() { private func restoreDb() {
@ -258,23 +208,6 @@ struct DatabaseErrorView: View {
)) ))
} }
} }
private func errorView(_ s: Text? = nil) -> some View {
VStack(spacing: 35) {
Image(systemName: "exclamationmark.triangle.fill")
.resizable()
.frame(width: 50, height: 50)
.foregroundColor(.red)
if let text = s {
text
.multilineTextAlignment(.center)
.font(.footnote)
}
}
.padding()
.frame(maxWidth: .infinity)
}
} }
struct DatabaseErrorView_Previews: PreviewProvider { struct DatabaseErrorView_Previews: PreviewProvider {

View file

@ -46,7 +46,6 @@ struct DatabaseView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let dismissSettingsSheet: DismissAction let dismissSettingsSheet: DismissAction
@State private var runChat = false @State private var runChat = false
@State private var stoppingChat = false
@State private var alert: DatabaseAlert? = nil @State private var alert: DatabaseAlert? = nil
@State private var showFileImporter = false @State private var showFileImporter = false
@State private var importedArchivePath: URL? @State private var importedArchivePath: URL?
@ -58,8 +57,6 @@ struct DatabaseView: View {
@State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var appFilesCountAndSize: (Int, Int)? @State private var appFilesCountAndSize: (Int, Int)?
@State private var showDatabaseEncryptionView = false
@State var chatItemTTL: ChatItemTTL @State var chatItemTTL: ChatItemTTL
@State private var currentChatItemTTL: ChatItemTTL = .none @State private var currentChatItemTTL: ChatItemTTL = .none
@ -72,20 +69,7 @@ struct DatabaseView: View {
} }
} }
@ViewBuilder
private func chatDatabaseView() -> some View { private func chatDatabaseView() -> some View {
NavigationLink(isActive: $showDatabaseEncryptionView) {
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
stopChatRunBlockStartChat(false, progressIndicator, block)
})
.navigationTitle("Database passphrase")
.modifier(ThemedBackground(grouped: true))
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
List { List {
let stopped = m.chatRunning == false let stopped = m.chatRunning == false
Section { Section {
@ -117,10 +101,9 @@ struct DatabaseView: View {
isOn: $runChat isOn: $runChat
) )
.onChange(of: runChat) { _ in .onChange(of: runChat) { _ in
if runChat { if (runChat) {
DatabaseView.startChat($runChat, $progressIndicator) startChat()
} else if !stoppingChat { } else {
stoppingChat = false
alert = .stopChat alert = .stopChat
} }
} }
@ -140,9 +123,7 @@ struct DatabaseView: View {
let color: Color = unencrypted ? .orange : theme.colors.secondary let color: Color = unencrypted ? .orange : theme.colors.secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) { settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink { NavigationLink {
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
stopChatRunBlockStartChat(false, progressIndicator, block)
})
.navigationTitle("Database passphrase") .navigationTitle("Database passphrase")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} label: { } label: {
@ -152,14 +133,9 @@ struct DatabaseView: View {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) { settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button("Export database") { Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted { if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
showDatabaseEncryptionView = true alert = .exportProhibited
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
alert = .exportProhibited
}
} else { } else {
stopChatRunBlockStartChat(stopped, $progressIndicator) { exportArchive()
await exportArchive()
}
} }
} }
} }
@ -168,6 +144,20 @@ struct DatabaseView: View {
showFileImporter = true showFileImporter = true
} }
} }
if let archiveName = chatArchiveName {
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
? "Old database archive"
: "New database archive"
settingsRow("archivebox", color: theme.colors.secondary) {
NavigationLink {
ChatArchiveView(archiveName: archiveName)
.navigationTitle(title)
.modifier(ThemedBackground(grouped: true))
} label: {
Text(title)
}
}
}
settingsRow("trash.slash", color: theme.colors.secondary) { settingsRow("trash.slash", color: theme.colors.secondary) {
Button("Delete database", role: .destructive) { Button("Delete database", role: .destructive) {
alert = .deleteChat alert = .deleteChat
@ -177,10 +167,14 @@ struct DatabaseView: View {
Text("Chat database") Text("Chat database")
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} footer: { } footer: {
Text("You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.") Text(
stopped
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
: "Stop chat to enable database actions"
)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
.disabled(progressIndicator) .disabled(!stopped)
if case .group = dbContainer, legacyDatabase { if case .group = dbContainer, legacyDatabase {
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) { Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
@ -196,7 +190,7 @@ struct DatabaseView: View {
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
alert = .deleteFilesAndMedia alert = .deleteFilesAndMedia
} }
.disabled(progressIndicator || appFilesCountAndSize?.0 == 0) .disabled(!stopped || appFilesCountAndSize?.0 == 0)
} header: { } header: {
Text("Files & media") Text("Files & media")
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
@ -261,9 +255,7 @@ struct DatabaseView: View {
title: Text("Import chat database?"), title: Text("Import chat database?"),
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Import")) { primaryButton: .destructive(Text("Import")) {
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) { importArchive(fileURL)
await DatabaseView.importArchive(fileURL, $progressIndicator, $alert, false)
}
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
@ -271,15 +263,19 @@ struct DatabaseView: View {
return Alert(title: Text("Error: no database file")) return Alert(title: Text("Error: no database file"))
} }
case .archiveImported: case .archiveImported:
let (title, message) = archiveImportedAlertText() return Alert(
return Alert(title: Text(title), message: Text(message)) title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database")
)
case let .archiveImportedWithErrors(errs): case let .archiveImportedWithErrors(errs):
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs) return Alert(
return Alert(title: Text(title), message: Text(message)) title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
)
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath]) showShareSheet(items: [archivePath])
} }
@ -289,17 +285,15 @@ struct DatabaseView: View {
title: Text("Delete chat profile?"), title: Text("Delete chat profile?"),
message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Delete")) { primaryButton: .destructive(Text("Delete")) {
let wasStopped = m.chatRunning == false deleteChat()
stopChatRunBlockStartChat(wasStopped, $progressIndicator) {
_ = await deleteChat()
return true
}
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
case .chatDeleted: case .chatDeleted:
let (title, message) = chatDeletedAlertText() return Alert(
return Alert(title: Text(title), message: Text(message)) title: Text("Chat database deleted"),
message: Text("Restart the app to create a new chat profile")
)
case .deleteLegacyDatabase: case .deleteLegacyDatabase:
return Alert( return Alert(
title: Text("Delete old database?"), title: Text("Delete old database?"),
@ -314,10 +308,7 @@ struct DatabaseView: View {
title: Text("Delete files and media?"), title: Text("Delete files and media?"),
message: Text("This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain."), message: Text("This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain."),
primaryButton: .destructive(Text("Delete")) { primaryButton: .destructive(Text("Delete")) {
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) { deleteFiles()
deleteFiles()
return true
}
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
@ -337,184 +328,95 @@ struct DatabaseView: View {
} }
} }
private func authStopChat(_ onStop: (() -> Void)? = nil) { private func authStopChat() {
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) { if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: stopChat(onStop) case .success: stopChat()
case .unavailable: stopChat(onStop) case .unavailable: stopChat()
case .failed: withAnimation { runChat = true } case .failed: withAnimation { runChat = true }
} }
} }
} else { } else {
stopChat(onStop) stopChat()
} }
} }
private func stopChat(_ onStop: (() -> Void)? = nil) { private func stopChat() {
Task { Task {
do { do {
try await stopChatAsync() try await stopChatAsync()
onStop?()
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
runChat = true runChat = true
showAlert("Error stopping chat", message: responseError(error)) alert = .error(title: "Error stopping chat", error: responseError(error))
} }
} }
} }
} }
func stopChatRunBlockStartChat( private func exportArchive() {
_ stopped: Bool, progressIndicator = true
_ progressIndicator: Binding<Bool>, Task {
_ block: @escaping () async throws -> Bool
) {
// if the chat was running, the sequence is: stop chat, run block, start chat.
// Otherwise, just run block and do nothing - the toggle will be visible anyway and the user can start the chat or not
if stopped {
Task {
do {
_ = try await block()
} catch {
logger.error("Error while executing block: \(error)")
}
}
} else {
authStopChat {
stoppingChat = true
runChat = false
Task {
// if it throws, let's start chat again anyway
var canStart = false
do {
canStart = try await block()
} catch {
logger.error("Error executing block: \(error)")
canStart = true
}
if canStart {
await MainActor.run {
DatabaseView.startChat($runChat, $progressIndicator)
}
}
}
}
}
}
static func startChat(_ runChat: Binding<Bool>, _ progressIndicator: Binding<Bool>) {
progressIndicator.wrappedValue = true
let m = ChatModel.shared
if m.chatDbChanged {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resetChatCtrl()
do {
let hadDatabase = hasDatabase()
try initializeChat(start: true)
m.chatDbChanged = false
AppChatState.shared.set(.active)
if m.chatDbStatus != .ok || !hadDatabase {
// Hide current view and show `DatabaseErrorView`
dismissAllSheets(animated: true)
}
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
progressIndicator.wrappedValue = false
}
} else {
do { do {
_ = try apiStartChat() let (archivePath, archiveErrors) = try await exportChatArchive()
runChat.wrappedValue = true if archiveErrors.isEmpty {
m.chatRunning = true showShareSheet(items: [archivePath])
ChatReceiver.shared.start() await MainActor.run { progressIndicator = false }
chatLastStartGroupDefault.set(Date.now) } else {
AppChatState.shared.set(.active) await MainActor.run {
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
progressIndicator = false
}
}
} catch let error { } catch let error {
runChat.wrappedValue = false
showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
}
progressIndicator.wrappedValue = false
}
}
private func exportArchive() async -> Bool {
await MainActor.run {
progressIndicator = true
}
do {
let (archivePath, archiveErrors) = try await exportChatArchive()
if archiveErrors.isEmpty {
showShareSheet(items: [archivePath])
await MainActor.run { progressIndicator = false }
} else {
await MainActor.run { await MainActor.run {
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors) alert = .error(title: "Error exporting chat database", error: responseError(error))
progressIndicator = false progressIndicator = false
} }
} }
} catch let error {
await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error))
progressIndicator = false
}
} }
return false
} }
static func importArchive( private func importArchive(_ archivePath: URL) {
_ archivePath: URL,
_ progressIndicator: Binding<Bool>,
_ alert: Binding<DatabaseAlert?>,
_ migration: Bool
) async -> Bool {
if archivePath.startAccessingSecurityScopedResource() { if archivePath.startAccessingSecurityScopedResource() {
defer { progressIndicator = true
archivePath.stopAccessingSecurityScopedResource() Task {
}
await MainActor.run {
progressIndicator.wrappedValue = true
}
do {
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
do { do {
let config = ArchiveConfig(archivePath: archivePath.path) try await apiDeleteStorage()
let archiveErrors = try await apiImportArchive(config: config) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
shouldImportAppSettingsDefault.set(true) do {
_ = kcDatabasePassword.remove() let config = ArchiveConfig(archivePath: archivePath.path)
if archiveErrors.isEmpty { let archiveErrors = try await apiImportArchive(config: config)
await operationEnded(.archiveImported, progressIndicator, alert) _ = kcDatabasePassword.remove()
return true if archiveErrors.isEmpty {
} else { await operationEnded(.archiveImported)
await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert) } else {
return migration await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors))
}
} catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
} }
} catch let error { } catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
} }
} catch let error { archivePath.stopAccessingSecurityScopedResource()
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
} }
} else { } else {
showAlert("Error accessing database file") alert = .error(title: "Error accessing database file")
} }
return false
} }
private func deleteChat() async -> Bool { private func deleteChat() {
await MainActor.run { progressIndicator = true
progressIndicator = true Task {
} do {
do { try await deleteChatAsync()
try await deleteChatAsync() await operationEnded(.chatDeleted)
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert) } catch let error {
return true await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
} catch let error { }
await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
return false
} }
} }
@ -526,30 +428,39 @@ struct DatabaseView: View {
} }
} }
private static func operationEnded(_ dbAlert: DatabaseAlert, _ progressIndicator: Binding<Bool>, _ alert: Binding<DatabaseAlert?>) async { private func operationEnded(_ dbAlert: DatabaseAlert) async {
await MainActor.run { await MainActor.run {
let m = ChatModel.shared
m.chatDbChanged = true m.chatDbChanged = true
m.chatInitialized = false m.chatInitialized = false
progressIndicator.wrappedValue = false progressIndicator = false
alert = dbAlert
} }
await withCheckedContinuation { cont in }
let okAlertActionWaiting = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default, handler: { _ in cont.resume() })
// show these alerts globally so they are visible when all sheets will be hidden private func startChat() {
if case .archiveImported = dbAlert { if m.chatDbChanged {
let (title, message) = archiveImportedAlertText() dismissSettingsSheet()
showAlert(title, message: message, actions: { [okAlertActionWaiting] }) DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
} else if case .archiveImportedWithErrors(let errs) = dbAlert { resetChatCtrl()
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs) do {
showAlert(title, message: message, actions: { [okAlertActionWaiting] }) try initializeChat(start: true)
} else if case .chatDeleted = dbAlert { m.chatDbChanged = false
let (title, message) = chatDeletedAlertText() AppChatState.shared.set(.active)
showAlert(title, message: message, actions: { [okAlertActionWaiting] }) } catch let error {
} else if case let .error(title, error) = dbAlert { fatalError("Error starting chat \(responseError(error))")
showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) }
} else { }
alert.wrappedValue = dbAlert } else {
cont.resume() do {
_ = try apiStartChat()
runChat = true
m.chatRunning = true
ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now)
AppChatState.shared.set(.active)
} catch let error {
runChat = false
alert = .error(title: "Error starting chat", error: responseError(error))
} }
} }
} }
@ -592,28 +503,8 @@ struct DatabaseView: View {
} }
} }
func archiveImportedAlertText() -> (String, String) { func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
( return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
NSLocalizedString("Chat database imported", comment: ""),
NSLocalizedString("Restart the app to use imported chat database", comment: "")
)
}
func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
(
NSLocalizedString("Chat database imported", comment: ""),
NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs)
)
}
private func chatDeletedAlertText() -> (String, String) {
(
NSLocalizedString("Chat database deleted", comment: ""),
NSLocalizedString("Restart the app to create a new chat profile", comment: "")
)
}
func archiveErrorsText(_ errs: [ArchiveError]) -> String {
return "\n" + errs.map(showArchiveError).joined(separator: "\n")
func showArchiveError(_ err: ArchiveError) -> String { func showArchiveError(_ err: ArchiveError) -> String {
switch err { switch err {

View file

@ -117,7 +117,7 @@ struct MigrateToAppGroupView: View {
setV3DBMigration(.migration_error) setV3DBMigration(.migration_error)
migrationError = "Error starting chat: \(responseError(error))" migrationError = "Error starting chat: \(responseError(error))"
} }
deleteOldChatArchive() deleteOldArchive()
} label: { } label: {
Text("Start chat") Text("Start chat")
.font(.title) .font(.title)
@ -235,16 +235,14 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
let errs = try await apiExportArchive(config: config) let errs = try await apiExportArchive(config: config)
if storagePath == nil { if storagePath == nil {
deleteOldChatArchive() deleteOldArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime) chatArchiveTimeDefault.set(archiveTime)
} }
return (archivePath, errs) return (archivePath, errs)
} }
/// Deprecated. Remove in the end of 2025. All unused archives should be deleted for the most users til then. func deleteOldArchive() {
/// Remove DEFAULT_CHAT_ARCHIVE_NAME and DEFAULT_CHAT_ARCHIVE_TIME as well
func deleteOldChatArchive() {
let d = UserDefaults.standard let d = UserDefaults.standard
if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) { if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) {
do { do {

View file

@ -11,12 +11,6 @@ import SwiftUI
class AppSheetState: ObservableObject { class AppSheetState: ObservableObject {
static let shared = AppSheetState() static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false @Published var scenePhaseActive: Bool = false
func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
!protectScreen || scenePhaseActive
? RedactionReasons()
: RedactionReasons.placeholder
}
} }
private struct PrivacySensitive: ViewModifier { private struct PrivacySensitive: ViewModifier {
@ -25,7 +19,11 @@ private struct PrivacySensitive: ViewModifier {
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared @ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.redacted(reason: appSheetState.redactionReasons(protectScreen)) if !protectScreen {
content
} else {
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
}
} }
} }

View file

@ -36,7 +36,12 @@ struct ChatItemClipped: ViewModifier {
.sndMsgContent, .sndMsgContent,
.rcvMsgContent, .rcvMsgContent,
.rcvDecryptionError, .rcvDecryptionError,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError, .rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: .invalidJSON:
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty { let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
false false

View file

@ -16,7 +16,6 @@ struct UserWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride @State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode? @State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false @State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool @Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@ -126,27 +125,24 @@ struct UserWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) }) CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
} else { } else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true } AdvancedSettingsButton(theme.colors.primary) { showMore = true }
} }
} }
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
)
} }
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool { private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@ -220,7 +216,6 @@ struct ChatWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride @State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode? @State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false @State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool @Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@ -333,27 +328,24 @@ struct ChatWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: editColor) CustomizeThemeColorsSection(editColor: editColor)
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
} else { } else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true } AdvancedSettingsButton(theme.colors.primary) { showMore = true }
} }
} }
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
)
} }
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool { private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {

View file

@ -1,62 +0,0 @@
//
// UserDefault.swift
// SimpleX (iOS)
//
// Created by user on 14/10/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
@propertyWrapper
public struct UserDefault<Value: Equatable>: DynamicProperty {
@StateObject private var observer = UserDefaultObserver()
let initialValue: Value
let key: String
let store: UserDefaults
public init(
wrappedValue: Value,
_ key: String,
store: UserDefaults = .standard
) {
self.initialValue = wrappedValue
self.key = key
self.store = store
}
public var wrappedValue: Value {
get {
// Observer can only be accessed after the property wrapper is installed in view (runtime exception)
observer.subscribe(to: key)
return store.object(forKey: key) as? Value ?? initialValue
}
nonmutating set {
store.set(newValue, forKey: key)
}
}
}
private class UserDefaultObserver: ObservableObject {
private var subscribed = false
func subscribe(to key: String) {
if !subscribed {
NotificationCenter.default.addObserver(
self,
selector: #selector(userDefaultsDidChange),
name: UserDefaults.didChangeNotification,
object: nil
)
subscribed = true
}
}
@objc
private func userDefaultsDidChange(_ notification: Notification) {
Task { @MainActor in objectWillChange.send() }
}
deinit { NotificationCenter.default.removeObserver(self) }
}

View file

@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
extension View { extension View {
@inline(__always)
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View { @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { if condition {
transform(self) transform(self)
@ -37,9 +36,9 @@ struct PrivacyBlur: ViewModifier {
.overlay { .overlay {
if (blurred && enabled) { if (blurred && enabled) {
Color.clear.contentShape(Rectangle()) Color.clear.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded { .onTapGesture {
blurred = false blurred = false
}) }
} }
} }
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in .onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in

View file

@ -65,7 +65,6 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created // Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil m.chatId = nil
ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatState.clear()
m.updateChats([]) m.updateChats([])
m.users = [] m.users = []
_ = kcAppPassword.set(password) _ = kcAppPassword.set(password)

View file

@ -28,7 +28,7 @@ struct PasscodeEntry: View {
} }
} }
private func passwordView() -> some View { @ViewBuilder private func passwordView() -> some View {
Text( Text(
password == "" password == ""
? " " ? " "

View file

@ -177,7 +177,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) } Task { await uploadArchive(path: archivePath) }
} }
@ -222,8 +222,7 @@ struct MigrateFromDevice: View {
} }
private func passphraseNotSetView() -> some View { private func passphraseNotSetView() -> some View {
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true, stopChatRunBlockStartChat: { _, _ in DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
})
.onChange(of: initialRandomDBPassphrase) { initial in .onChange(of: initialRandomDBPassphrase) { initial in
if !initial { if !initial {
migrationState = .uploadConfirmation migrationState = .uploadConfirmation
@ -520,15 +519,15 @@ struct MigrateFromDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run { await MainActor.run {
switch msg { switch msg {
case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)): case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize):
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
} }
case .result(.sndFileRedirectStartXFTP): case .sndFileRedirectStartXFTP:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkCreation migrationState = .linkCreation
} }
case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)): case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
let cfg = getNetCfg() let cfg = getNetCfg()
let proxy: NetworkProxy? = if cfg.socksProxy == nil { let proxy: NetworkProxy? = if cfg.socksProxy == nil {
nil nil
@ -546,7 +545,7 @@ struct MigrateFromDevice: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl)
} }
case .result(.sndFileError): case .sndFileError:
alert = .error(title: "Upload failed", error: "Check your internet connection and try again") alert = .error(title: "Upload failed", error: "Check your internet connection and try again")
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
default: default:
@ -691,7 +690,7 @@ private struct PassphraseConfirmationView: View {
migrationState = .uploadConfirmation migrationState = .uploadConfirmation
} }
} catch let error { } catch let error {
if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError { if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else { } else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error))) alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
@ -733,11 +732,11 @@ func chatStoppedView() -> some View {
private class MigrationChatReceiver { private class MigrationChatReceiver {
let ctrl: chat_ctrl let ctrl: chat_ctrl
let databaseUrl: URL let databaseUrl: URL
let processReceivedMsg: (APIResult<ChatEvent>) async -> Void let processReceivedMsg: (ChatResponse) async -> Void
private var receiveLoop: Task<Void, Never>? private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true private var receiveMessages = true
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) { init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
self.ctrl = ctrl self.ctrl = ctrl
self.databaseUrl = databaseUrl self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg self.processReceivedMsg = processReceivedMsg
@ -752,9 +751,9 @@ private class MigrationChatReceiver {
func receiveMsgLoop() async { func receiveMsgLoop() async {
// TODO use function that has timeout // TODO use function that has timeout
if let msg: APIResult<ChatEvent> = await chatRecvMsg(ctrl) { if let msg = await chatRecvMsg(ctrl) {
Task { Task {
await TerminalItems.shared.addResult(msg) await TerminalItems.shared.add(.resp(.now, msg))
} }
logger.debug("processReceivedMsg: \(msg.responseType)") logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg) await processReceivedMsg(msg)

View file

@ -96,7 +96,6 @@ struct MigrateToDevice: View {
@Binding var migrationState: MigrationToState? @Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert? @State private var alert: MigrateToDeviceViewAlert?
@State private var databaseAlert: DatabaseAlert? = nil
private let tempDatabaseUrl = urlForTemporaryDatabase() private let tempDatabaseUrl = urlForTemporaryDatabase()
@State private var chatReceiver: MigrationChatReceiver? = nil @State private var chatReceiver: MigrationChatReceiver? = nil
// Prevent from hiding the view until migration is finished or app deleted // Prevent from hiding the view until migration is finished or app deleted
@ -104,9 +103,6 @@ struct MigrateToDevice: View {
@State private var showQRCodeScanner: Bool = true @State private var showQRCodeScanner: Bool = true
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
@State private var importingArchiveFromFileProgressIndicator = false
@State private var showFileImporter = false
var body: some View { var body: some View {
VStack { VStack {
switch migrationState { switch migrationState {
@ -179,20 +175,6 @@ struct MigrateToDevice: View {
return Alert(title: Text(title), message: Text(error)) return Alert(title: Text(title), message: Text(error))
} }
} }
.alert(item: $databaseAlert) { item in
switch item {
case .archiveImported:
let (title, message) = archiveImportedAlertText()
return Alert(title: Text(title), message: Text(message))
case let .archiveImportedWithErrors(errs):
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
return Alert(title: Text(title), message: Text(message))
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
default: // not expected this branch to be called because this alert is used only for importArchive purpose
return Alert(title: Text("Error"))
}
}
.interactiveDismissDisabled(backDisabled) .interactiveDismissDisabled(backDisabled)
} }
@ -218,12 +200,6 @@ struct MigrateToDevice: View {
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) { Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
pasteLinkView() pasteLinkView()
} }
Section(header: Text("Or import archive file").foregroundColor(theme.colors.secondary)) {
archiveImportFromFileView()
}
}
if importingArchiveFromFileProgressIndicator {
progressView()
} }
} }
} }
@ -244,34 +220,6 @@ struct MigrateToDevice: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
private func archiveImportFromFileView() -> some View {
Button {
showFileImporter = true
} label: {
Label("Import database", systemImage: "square.and.arrow.down")
}
.disabled(importingArchiveFromFileProgressIndicator)
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.zip],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
Task {
let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true)
if success {
DatabaseView.startChat(
Binding.constant(false),
$importingArchiveFromFileProgressIndicator
)
hideView()
}
}
}
}
}
private func linkDownloadingView(_ link: String) -> some View { private func linkDownloadingView(_ link: String) -> some View {
ZStack { ZStack {
List { List {
@ -496,10 +444,10 @@ struct MigrateToDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run { await MainActor.run {
switch msg { switch msg {
case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)): case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
case .result(.rcvStandaloneFileComplete): case .rcvStandaloneFileComplete:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// User closed the whole screen before new state was saved // User closed the whole screen before new state was saved
if migrationState == nil { if migrationState == nil {
@ -509,10 +457,10 @@ struct MigrateToDevice: View {
MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
} }
} }
case .result(.rcvFileError): case .rcvFileError:
alert = .error(title: "Download failed", error: "File was deleted or link is invalid") alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
case .error(.error(.noRcvFileUser)): case .chatError(_, .error(.noRcvFileUser)):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid") alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
default: default:
@ -539,7 +487,7 @@ struct MigrateToDevice: View {
chatInitControllerRemovingDatabases() chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true { } else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running // cannot delete storage if chat is running
try await stopChatAsync() try await apiStopChat()
} }
try await apiDeleteStorage() try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@ -623,7 +571,7 @@ struct MigrateToDevice: View {
AlertManager.shared.showAlert( AlertManager.shared.showAlert(
Alert( Alert(
title: Text("Error migrating settings"), title: Text("Error migrating settings"),
message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error))) message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
) )
} }
hideView() hideView()
@ -632,8 +580,6 @@ struct MigrateToDevice: View {
private func hideView() { private func hideView() {
onboardingStageDefault.set(.onboardingComplete) onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete m.onboardingStage = .onboardingComplete
m.migrationState = nil
MigrationToDeviceState.save(nil)
dismiss() dismiss()
} }
@ -751,11 +697,11 @@ private func progressView() -> some View {
private class MigrationChatReceiver { private class MigrationChatReceiver {
let ctrl: chat_ctrl let ctrl: chat_ctrl
let databaseUrl: URL let databaseUrl: URL
let processReceivedMsg: (APIResult<ChatEvent>) async -> Void let processReceivedMsg: (ChatResponse) async -> Void
private var receiveLoop: Task<Void, Never>? private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true private var receiveMessages = true
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) { init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
self.ctrl = ctrl self.ctrl = ctrl
self.databaseUrl = databaseUrl self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg self.processReceivedMsg = processReceivedMsg
@ -772,7 +718,7 @@ private class MigrationChatReceiver {
// TODO use function that has timeout // TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) { if let msg = await chatRecvMsg(ctrl) {
Task { Task {
await TerminalItems.shared.addResult(msg) await TerminalItems.shared.add(.resp(.now, msg))
} }
logger.debug("processReceivedMsg: \(msg.responseType)") logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg) await processReceivedMsg(msg)

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