Compare commits

..

1 Commits

Author SHA1 Message Date
Avently 29857e33e2 multiplatform: init haskell in different thread 2023-11-21 18:47:01 +07:00
531 changed files with 14451 additions and 46522 deletions
+20 -47
View File
@@ -9,7 +9,6 @@ on:
tags: tags:
- "v*" - "v*"
- "!*-fdroid" - "!*-fdroid"
- "!*-armv7a"
pull_request: pull_request:
jobs: jobs:
@@ -43,7 +42,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build: build:
name: build-${{ matrix.os }}-${{ matrix.ghc }} name: build-${{ matrix.os }}
if: always() if: always()
needs: prepare-release needs: prepare-release
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -52,25 +51,18 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-20.04 - os: ubuntu-20.04
ghc: "8.10.7"
cache_path: ~/.cabal/store
- os: ubuntu-20.04
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64 asset_name: simplex-chat-ubuntu-20_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04 - os: ubuntu-22.04
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64 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
- os: macos-latest - os: macos-latest
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64 asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest - os: windows-latest
ghc: "9.6.3"
cache_path: C:/cabal cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64 asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi desktop_asset_name: simplex-desktop-windows-x86_64.msi
@@ -89,17 +81,16 @@ jobs:
- name: Setup Haskell - name: Setup Haskell
uses: haskell-actions/setup@v2 uses: haskell-actions/setup@v2
with: with:
ghc-version: ${{ matrix.ghc }} ghc-version: "9.6.3"
cabal-version: "3.10.1.0" cabal-version: "3.10.1.0"
- name: Restore cached build - name: Cache dependencies
id: restore_cache uses: actions/cache@v3
uses: actions/cache/restore@v3
with: with:
path: | path: |
${{ matrix.cache_path }} ${{ matrix.cache_path }}
dist-newstyle dist-newstyle
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
# / Unix # / Unix
@@ -114,7 +105,7 @@ jobs:
echo " flags: +openssl" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies - name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac - name: Install pkg-config for Mac
@@ -140,7 +131,7 @@ jobs:
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $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: Unix upload CLI binary to release - name: Unix upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -149,7 +140,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Unix update CLI binary hash - name: Unix update CLI binary hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -159,7 +150,7 @@ jobs:
${{ steps.unix_cli_build.outputs.bin_hash }} ${{ steps.unix_cli_build.outputs.bin_hash }}
- name: Setup Java - name: Setup Java
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name if: startsWith(github.ref, 'refs/tags/v')
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'corretto' distribution: 'corretto'
@@ -168,7 +159,7 @@ jobs:
- name: Linux build desktop - name: Linux build desktop
id: linux_desktop_build id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
shell: bash shell: bash
run: | run: |
scripts/desktop/build-lib-linux.sh scripts/desktop/build-lib-linux.sh
@@ -177,10 +168,10 @@ jobs:
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb) path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Linux make AppImage - name: Linux make AppImage
id: linux_appimage_build id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
shell: bash shell: bash
run: | run: |
scripts/desktop/make-appimage-linux.sh scripts/desktop/make-appimage-linux.sh
@@ -203,7 +194,7 @@ jobs:
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $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: Linux upload desktop package to release - name: Linux upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -212,7 +203,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Linux update desktop package hash - name: Linux update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -222,7 +213,7 @@ jobs:
${{ steps.linux_desktop_build.outputs.package_hash }} ${{ steps.linux_desktop_build.outputs.package_hash }}
- name: Linux upload AppImage to release - name: Linux upload AppImage to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -231,7 +222,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Linux update AppImage hash - name: Linux update AppImage hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -259,15 +250,6 @@ jobs:
body: | body: |
${{ steps.mac_desktop_build.outputs.package_hash }} ${{ 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 - name: Unix test
if: matrix.os != 'windows-latest' if: matrix.os != 'windows-latest'
timeout-minutes: 30 timeout-minutes: 30
@@ -280,7 +262,7 @@ jobs:
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
- name: 'Setup MSYS2' - name: 'Setup MSYS2'
if: matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: msys2/setup-msys2@v2 uses: msys2/setup-msys2@v2
with: with:
msystem: ucrt64 msystem: ucrt64
@@ -299,7 +281,7 @@ jobs:
if: matrix.os == 'windows-latest' 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
scripts/desktop/prepare-openssl-windows.sh scripts/desktop/prepare-openssl-windows.sh
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-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
@@ -341,14 +323,14 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' 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
scripts/desktop/build-lib-windows.sh scripts/desktop/build-lib-windows.sh
cd apps/multiplatform cd apps/multiplatform
./gradlew packageMsi ./gradlew packageMsi
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-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $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: Windows upload desktop package to release - name: Windows upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
@@ -368,13 +350,4 @@ jobs:
body: | body: |
${{ steps.windows_desktop_build.outputs.package_hash }} ${{ 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 / # Windows /
+16 -25
View File
@@ -1,41 +1,32 @@
ARG TAG=22.04 FROM ubuntu:focal AS build
FROM ubuntu:${TAG} AS build # Install curl and simplex-chat-related dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
### Build stage
# Install curl and git and simplex-chat dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
# Install ghcup # Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
chmod +x /usr/bin/ghcup
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Install ghc
RUN ghcup install ghc 9.6.3
# Install cabal
RUN ghcup install cabal 3.10.1.0
# Set both as default # Set both as default
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \ RUN ghcup set ghc 9.6.3 && \
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}" ghcup set cabal 3.10.1.0
COPY . /project COPY . /project
WORKDIR /project WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build # Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local 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 RUN cabal install
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
mv "$bin" ./ && \
strip ./simplex-chat
# Copy compiled app from build stage
FROM scratch AS export-stage FROM scratch AS export-stage
COPY --from=build /project/simplex-chat / COPY --from=build /root/.cabal/bin/simplex-chat /
+8 -15
View File
@@ -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=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform: There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
@@ -127,7 +127,6 @@ Join our translators to help SimpleX grow!
|🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)|| |🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)| |🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-||| |🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|🇭🇺 hu|Magyar | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/hu/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/hu/)<br>-|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)|| |🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)|| |🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)|| |🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
@@ -135,7 +134,6 @@ Join our translators to help SimpleX grow!
|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)|| |🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)||| |🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)||| |🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|🇹🇷 tr|Türkçe | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/tr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/tr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/tr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)|||
|🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)|| |🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)|| |🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
@@ -234,10 +232,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md). [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). [Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
@@ -301,7 +295,7 @@ What is already implemented:
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections. 11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay. 12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). 13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Local files encryption. 14. Local files encryption, except videos (to be added later).
We plan to add: We plan to add:
@@ -372,14 +366,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Message delivery confirmation (with sender opt-out per contact). - ✅ Message delivery confirmation (with sender opt-out per contact).
- ✅ Desktop client. - ✅ Desktop client.
- ✅ Encryption of local files stored in the app. - ✅ Encryption of local files stored in the app.
- Using mobile profiles from the desktop app. - 🏗 Using mobile profiles from the desktop app.
- ✅ Private notes. - Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Improve sending videos (including encryption of locally stored videos). - Post-quantum resistant key exchange in double ratchet protocol.
- 🏗 Improve experience for the new users. - Large groups, communities and public channels.
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
- 🏗 Large groups, communities and public channels.
- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- Privacy & security slider - a simple way to set all settings at once. - Privacy & security slider - a simple way to set all settings at once.
- Improve sending videos (including encryption of locally stored videos).
- Improve experience for the new users.
- 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.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
+1 -7
View File
@@ -15,7 +15,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
logger.debug("AppDelegate: didFinishLaunchingWithOptions") logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
if #available(iOS 17.0, *) { trackKeyboard() } if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
return true return true
} }
@@ -37,17 +36,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
ChatModel.shared.keyboardHeight = 0 ChatModel.shared.keyboardHeight = 0
} }
@objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
let m = ChatModel.shared let m = ChatModel.shared
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken m.deviceToken = deviceToken
// savedToken is set in startChat, when it is started before this method is called
if m.savedToken != nil { if m.savedToken != nil {
registerToken(token: deviceToken) registerToken(token: deviceToken)
} }
@@ -86,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
} }
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo { if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
receiveMessages(completionHandler) receiveMessages(completionHandler)
} else { } else {
completionHandler(.noData) completionHandler(.noData)
+66 -153
View File
@@ -14,14 +14,11 @@ struct ContentView: View {
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Binding var doAuthenticate: Bool
var contentAccessAuthenticationExtended: Bool @Binding var userAuthorized: Bool?
@Binding var canConnectCall: Bool
@Environment(\.scenePhase) var scenePhase @Binding var lastSuccessfulUnlock: TimeInterval?
@State private var automaticAuthenticationAttempted = false @Binding var showInitializationView: Bool
@State private var canConnectViewCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -31,11 +28,8 @@ struct ContentView: View {
@State private var showWhatsNew = false @State private var showWhatsNew = false
@State private var showChooseLAMode = false @State private var showChooseLAMode = false
@State private var showSetPasscode = false @State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -46,43 +40,16 @@ struct ContentView: View {
} }
} }
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
var body: some View { var body: some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted contentView()
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
if !prefPerformLA || accessAuthenticated {
contentView()
.padding(.top, showCallArea ? callTopPadding : 0)
} else {
lockButton()
.padding(.top, showCallArea ? callTopPadding : 0)
}
if showCallArea, let call = chatModel.activeCall {
VStack {
activeCallInteractiveArea(call)
Spacer()
}
}
if chatModel.showCallView, let call = chatModel.activeCall { if chatModel.showCallView, let call = chatModel.activeCall {
callView(call) callView(call)
} }
if !showSettings, let la = chatModel.laRequest { if !showSettings, let la = chatModel.laRequest {
LocalAuthView(authRequest: la) LocalAuthView(authRequest: la)
.onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
waitingForOrPassedAuth = accessAuthenticated
}
} else if showSetPasscode { } else if showSetPasscode {
SetAppPasscodeView { SetAppPasscodeView {
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
showSetPasscode = false showSetPasscode = false
privacyLocalAuthModeDefault.set(.passcode) privacyLocalAuthModeDefault.set(.passcode)
@@ -92,10 +59,15 @@ struct ContentView: View {
showSetPasscode = false showSetPasscode = false
alertManager.showAlert(laPasscodeNotSetAlert()) alertManager.showAlert(laPasscodeNotSetAlert())
} }
} else if chatModel.chatDbStatus == nil && AppChatState.shared.value != .stopped && waitingForOrPassedAuth {
initializationView()
} }
} }
.onAppear {
if prefPerformLA { requestNtfAuthorization() }
initAuthenticate()
}
.onChange(of: doAuthenticate) { _ in
initAuthenticate()
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView(showSettings: $showSettings) SettingsView(showSettings: $showSettings)
@@ -104,44 +76,14 @@ struct ContentView: View {
Button("System authentication") { initialEnableLA() } Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true } Button("Passcode entry") { showSetPasscode = true }
} }
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
switch (phase) {
case .background:
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
automaticAuthenticationAttempted = false
canConnectViewCall = false
case .active:
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
if AppChatState.shared.value != .stopped {
if contentAccessAuthenticationExtended {
chatModel.contentViewAccessAuthenticated = true
} else {
if !automaticAuthenticationAttempted {
automaticAuthenticationAttempted = true
// authenticate if call kit call is not in progress
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
authenticateContentViewAccess()
}
}
}
} else {
// when app is stopped automatic authentication is not attempted
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
}
}
default:
break
}
}
} }
@ViewBuilder private func contentView() -> some View { @ViewBuilder private func contentView() -> some View {
if let status = chatModel.chatDbStatus, status != .ok { if prefPerformLA && userAuthorized != true {
lockButton()
} else if chatModel.chatDbStatus == nil && showInitializationView {
initializationView()
} else if let status = chatModel.chatDbStatus, status != .ok {
DatabaseErrorView(status: status) DatabaseErrorView(status: status)
} else if !chatModel.v3DBMigration.startChat { } else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView() MigrateToAppGroupView()
@@ -149,11 +91,11 @@ struct ContentView: View {
if case .onboardingComplete = step, if case .onboardingComplete = step,
chatModel.currentUser != nil { chatModel.currentUser != nil {
mainView() mainView()
.actionSheet(item: $chatListActionSheet) { sheet in .actionSheet(item: $chatListActionSheet) { sheet in
switch sheet { switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
} }
}
} else { } else {
OnboardingView(onboarding: step) OnboardingView(onboarding: step)
} }
@@ -164,11 +106,11 @@ struct ContentView: View {
if CallController.useCallKit() { if CallController.useCallKit() {
ActiveCallView(call: call, canConnectCall: Binding.constant(true)) ActiveCallView(call: call, canConnectCall: Binding.constant(true))
.onDisappear { .onDisappear {
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } if userAuthorized == false && doAuthenticate { runAuthenticate() }
} }
} else { } else {
ActiveCallView(call: call, canConnectCall: $canConnectViewCall) ActiveCallView(call: call, canConnectCall: $canConnectCall)
if prefPerformLA && !accessAuthenticated { if prefPerformLA && userAuthorized != true {
Rectangle() Rectangle()
.fill(colorScheme == .dark ? .black : .white) .fill(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -177,64 +119,25 @@ struct ContentView: View {
} }
} }
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer()
CallDuration(call: call)
}
.padding(.horizontal)
.frame(height: callTopPadding - 10)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
}
}
struct CallDuration: View {
let call: Call
@State var text: String = ""
@State var timer: Timer? = nil
var body: some View {
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
if let connectedAt = call.connectedAt {
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
}
}
}
.onDisappear {
_ = timer?.invalidate()
}
}
}
private func lockButton() -> some View { private func lockButton() -> some View {
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
} }
private func initializationView() -> some View { private func initializationView() -> some View {
VStack { VStack {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
Text("Opening app") Text("Opening database")
.padding() .padding()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
.fill(.background)
)
} }
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear { .onAppear {
requestNtfAuthorization() if !prefPerformLA { 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
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) { if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
prefLANoticeShown = true prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert()) alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil { } else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
@@ -284,37 +187,48 @@ struct ContentView: View {
} }
} }
private func unlockedRecently() -> Bool { private func initAuthenticate() {
if let lastSuccessfulUnlock = lastSuccessfulUnlock { logger.debug("initAuthenticate")
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
} else { userAuthorized = false
return false } else if doAuthenticate {
runAuthenticate()
} }
} }
private func authenticateContentViewAccess() { private func runAuthenticate() {
logger.debug("DEBUGGING: authenticateContentViewAccess") logger.debug("DEBUGGING: runAuthenticate")
dismissAllSheets(animated: false) { if !prefPerformLA {
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") userAuthorized = true
chatModel.chatId = nil } else {
logger.debug("DEBUGGING: before dismissAllSheets")
dismissAllSheets(animated: false) {
logger.debug("DEBUGGING: in dismissAllSheets callback")
chatModel.chatId = nil
justAuthenticate()
}
}
}
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in private func justAuthenticate() {
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") userAuthorized = false
switch (laResult) { let laMode = privacyLocalAuthModeDefault.get()
case .success: authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
chatModel.contentViewAccessAuthenticated = true logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
canConnectViewCall = true switch (laResult) {
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .success:
case .failed: userAuthorized = true
chatModel.contentViewAccessAuthenticated = false canConnectCall = true
if privacyLocalAuthModeDefault.get() == .passcode { lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
AlertManager.shared.showAlert(laFailedAlert()) case .failed:
} if laMode == .passcode {
case .unavailable: AlertManager.shared.showAlert(laFailedAlert())
prefPerformLA = false
canConnectViewCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
case .unavailable:
userAuthorized = true
prefPerformLA = false
canConnectCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
} }
} }
@@ -345,7 +259,6 @@ struct ContentView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: case .success:
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
alertManager.showAlert(laTurnedOnAlert()) alertManager.showAlert(laTurnedOnAlert())
case .failed: case .failed:
+6 -28
View File
@@ -15,13 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive"
// TCP timeout + 2 sec // TCP timeout + 2 sec
private let waitForMessages: TimeInterval = 6 private let waitForMessages: TimeInterval = 6
// This is the smallest interval between refreshes, and also target interval in "off" mode private let bgRefreshInterval: TimeInterval = 450
private let bgRefreshInterval: TimeInterval = 600 // 10 minutes
// This intervals are used for background refresh in instant and periodic modes
private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes
private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
private let maxTimerCount = 9 private let maxTimerCount = 9
@@ -39,14 +33,14 @@ class BGManager {
} }
} }
func schedule(interval: TimeInterval? = nil) { func schedule() {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.schedule: disabled") logger.debug("BGManager.schedule: disabled")
return return
} }
logger.debug("BGManager.schedule") logger.debug("BGManager.schedule")
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval) request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
@@ -54,34 +48,20 @@ class BGManager {
} }
} }
var runInterval: TimeInterval {
switch ChatModel.shared.notificationMode {
case .instant: maxBgRefreshInterval
case .periodic: periodicBgRefreshInterval
case .off: bgRefreshInterval
}
}
var lastRanLongAgo: Bool {
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
}
private func handleRefresh(_ task: BGAppRefreshTask) { private func handleRefresh(_ task: BGAppRefreshTask) {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.handleRefresh: disabled") logger.debug("BGManager.handleRefresh: disabled")
return return
} }
logger.debug("BGManager.handleRefresh") logger.debug("BGManager.handleRefresh")
let shouldRun_ = lastRanLongAgo schedule()
if allowBackgroundRefresh() && shouldRun_ { if appStateGroupDefault.get().inactive {
schedule()
let completeRefresh = completionHandler { let completeRefresh = completionHandler {
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
task.expirationHandler = { completeRefresh("expirationHandler") } task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh) receiveMessages(completeRefresh)
} else { } else {
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
logger.debug("BGManager.completionHandler: already active, not started") logger.debug("BGManager.completionHandler: already active, not started")
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
@@ -110,22 +90,20 @@ class BGManager {
} }
self.completed = false self.completed = false
DispatchQueue.main.async { DispatchQueue.main.async {
chatLastBackgroundRunGroupDefault.set(Date.now)
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
setAppState(.bgRefresh)
do { do {
try initializeChat(start: true) try initializeChat(start: true)
} catch let error { } catch let error {
fatalError("Failed to start or load chats: \(responseError(error))") fatalError("Failed to start or load chats: \(responseError(error))")
} }
} }
activateChat(appState: .bgRefresh)
if m.currentUser == nil { if m.currentUser == nil {
completeReceiving("no current user") completeReceiving("no current user")
return return
} }
logger.debug("BGManager.receiveMessages: starting chat") logger.debug("BGManager.receiveMessages: starting chat")
activateChat(appState: .bgRefresh)
let cr = ChatReceiver() let cr = ChatReceiver()
self.chatReceiver = cr self.chatReceiver = cr
cr.start() cr.start()
+14 -47
View File
@@ -54,13 +54,9 @@ final class ChatModel: ObservableObject {
@Published var chatDbChanged = false @Published var chatDbChanged = false
@Published var chatDbEncrypted: Bool? @Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult? @Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest? @Published var laRequest: LocalAuthRequest?
// list of chat "previews" // list of chat "previews"
@Published var chats: [Chat] = [] @Published var chats: [Chat] = []
@Published var deletedChats: Set<String> = []
// map of connections network statuses, key is agent connection id // map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:] @Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat // current chat
@@ -80,7 +76,6 @@ final class ChatModel: ObservableObject {
@Published var tokenRegistered = false @Published var tokenRegistered = false
@Published var tokenStatus: NtfTknStatus? @Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off @Published var notificationMode = NotificationsMode.off
@Published var notificationServer: String?
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
// pending notification actions // pending notification actions
@Published var ntfContactRequest: NTFContactRequest? @Published var ntfContactRequest: NTFContactRequest?
@@ -88,20 +83,18 @@ final class ChatModel: ObservableObject {
// current WebRTC call // current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:] @Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call? @Published var activeCall: Call?
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() @Published var callCommand: WCallCommand?
@Published var showCallView = false @Published var showCallView = false
@Published var activeCallViewIsCollapsed = false
// remote desktop // remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession? @Published var remoteCtrlSession: RemoteCtrlSession?
// currently showing invitation // currently showing QR code
@Published var showingInvitation: ShowingInvitation? @Published var connReqInv: String?
// audio recording and playback // audio recording and playback
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState? @Published var draft: ComposeState?
@Published var draftChatId: String? @Published var draftChatId: String?
// tracks keyboard height via subscription in AppDelegate // tracks keyboard height via subscription in AppDelegate
@Published var keyboardHeight: CGFloat = 0 @Published var keyboardHeight: CGFloat = 0
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
var messageDelivery: Dictionary<Int64, () -> Void> = [:] var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -111,10 +104,12 @@ final class ChatModel: ObservableObject {
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true var ntfEnableLocal: Bool {
notificationMode == .off || ntfEnableLocalGroupDefault.get()
}
var ntfEnablePeriodic: Bool { var ntfEnablePeriodic: Bool {
notificationMode != .off notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
} }
var activeRemoteCtrl: Bool { var activeRemoteCtrl: Bool {
@@ -141,7 +136,7 @@ final class ChatModel: ObservableObject {
} }
func removeUser(_ user: User) { func removeUser(_ user: User) {
if let i = getUserIndex(user) { if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId {
users.remove(at: i) users.remove(at: i)
} }
} }
@@ -272,20 +267,7 @@ final class ChatModel: ObservableObject {
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews // update previews
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo { chats[i].chatItems = [cItem]
case .group:
if let currentPreviewItem = chats[i].chatItems.first {
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
[cItem]
} else {
[currentPreviewItem]
}
} else {
[cItem]
}
default:
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!) increaseUnreadCounter(user: currentUser!)
@@ -625,16 +607,14 @@ final class ChatModel: ObservableObject {
} }
func dismissConnReqView(_ id: String) { func dismissConnReqView(_ id: String) {
if id == showingInvitation?.connId { if let connReqInv = connReqInv,
markShowingInvitationUsed() let c = getChat(id),
case let .contactConnection(contactConnection) = c.chatInfo,
connReqInv == contactConnection.connReqInv {
dismissAllSheets() dismissAllSheets()
} }
} }
func markShowingInvitationUsed() {
showingInvitation?.connChatUsed = true
}
func removeChat(_ id: String) { func removeChat(_ id: String) {
withAnimation { withAnimation {
chats.removeAll(where: { $0.id == id }) chats.removeAll(where: { $0.id == id })
@@ -711,11 +691,6 @@ final class ChatModel: ObservableObject {
} }
} }
struct ShowingInvitation {
var connId: String
var connChatUsed: Bool
}
struct NTFContactRequest { struct NTFContactRequest {
var incognito: Bool var incognito: Bool
var chatId: String var chatId: String
@@ -758,8 +733,6 @@ final class Chat: ObservableObject, Identifiable {
case let .group(groupInfo): case let .group(groupInfo):
let m = groupInfo.membership let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member return m.memberActive && m.memberRole >= .member
case .local:
return true
default: return false default: return false
} }
} }
@@ -797,7 +770,7 @@ final class GMember: ObservableObject, Identifiable {
} }
struct RemoteCtrlSession { struct RemoteCtrlSession {
var ctrlAppInfo: CtrlAppInfo? var ctrlAppInfo: CtrlAppInfo
var appVersion: String var appVersion: String
var sessionState: UIRemoteCtrlSessionState var sessionState: UIRemoteCtrlSessionState
@@ -809,10 +782,6 @@ struct RemoteCtrlSession {
if case .connected = sessionState { true } else { false } if case .connected = sessionState { true } else { false }
} }
var discovery: Bool {
if case .searching = sessionState { true } else { false }
}
var sessionCode: String? { var sessionCode: String? {
switch sessionState { switch sessionState {
case let .pendingConfirmation(_, sessionCode): sessionCode case let .pendingConfirmation(_, sessionCode): sessionCode
@@ -824,8 +793,6 @@ struct RemoteCtrlSession {
enum UIRemoteCtrlSessionState { enum UIRemoteCtrlSessionState {
case starting case starting
case searching
case found(remoteCtrl: RemoteCtrlInfo, compatible: Bool)
case connecting(remoteCtrl_: RemoteCtrlInfo?) case connecting(remoteCtrl_: RemoteCtrlInfo?)
case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String)
+7 -17
View File
@@ -158,8 +158,7 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
func saveFileFromURL(_ url: URL) -> CryptoFile? { func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile? let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
do { do {
@@ -186,37 +185,28 @@ func saveFileFromURL(_ url: URL) -> CryptoFile? {
func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do { do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
let savedFile: CryptoFile? try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
if encrypted {
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path)
try FileManager.default.removeItem(atPath: url.path)
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
} else {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName)
}
ChatModel.shared.filesToDelete.remove(url) ChatModel.shared.filesToDelete.remove(url)
return savedFile return CryptoFile.plain(fileName)
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
return nil return nil
} }
} }
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String { func generateNewFileName(_ prefix: String, _ ext: String) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
} }
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String { private func uniqueCombine(_ fileName: String) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString let ns = fileName as NSString
let name = ns.deletingPathExtension let name = ns.deletingPathExtension
let ext = ns.pathExtension let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)" let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)" let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
} }
return tryCombine(fileName, 0) return tryCombine(fileName, 0)
} }
-83
View File
@@ -1,83 +0,0 @@
//
// NSESubscriber.swift
// SimpleXChat
//
// Created by Evgeny on 09/12/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
private var nseSubscribers: [UUID:NSESubscriber] = [:]
// timeout for active notification service extension going into "suspending" state.
// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call.
private let SUSPENDING_TIMEOUT: TimeInterval = 2
// timeout should be larger than SUSPENDING_TIMEOUT
func waitNSESuspended(timeout: TimeInterval, suspended: @escaping (Bool) -> Void) {
if timeout <= SUSPENDING_TIMEOUT {
logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)")
}
var state = nseStateGroupDefault.get()
if case .suspended = state {
DispatchQueue.main.async { suspended(true) }
return
}
let id = UUID()
var suspendedCalled = false
checkTimeout()
nseSubscribers[id] = nseMessageSubscriber { msg in
if case let .state(newState) = msg {
state = newState
logger.debug("waitNSESuspended state: \(state.rawValue)")
if case .suspended = newState {
notifySuspended(true)
}
}
}
return
func notifySuspended(_ ok: Bool) {
logger.debug("waitNSESuspended notifySuspended: \(ok)")
if !suspendedCalled {
logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))")
suspendedCalled = true
nseSubscribers.removeValue(forKey: id)
DispatchQueue.main.async { suspended(ok) }
}
}
func checkTimeout() {
if !suspending() {
checkSuspendingTimeout()
} else if state == .suspending {
checkSuspendedTimeout()
}
}
func suspending() -> Bool {
suspendedCalled || state == .suspended || state == .suspending
}
func checkSuspendingTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) {
logger.debug("waitNSESuspended check suspending timeout")
if !suspending() {
notifySuspended(false)
} else if state != .suspended {
checkSuspendedTimeout()
}
}
}
func checkSuspendedTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) {
logger.debug("waitNSESuspended check suspended timeout")
if state != .suspended {
notifySuspended(false)
}
}
}
}
+68 -161
View File
@@ -211,7 +211,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
} }
func apiStartChat() throws -> Bool { func apiStartChat() throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true)) let r = chatSendCmdSync(.startChat(subscribe: true, expire: true, xftp: true))
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -228,8 +228,7 @@ func apiStopChat() async throws {
} }
func apiActivateChat() { func apiActivateChat() {
chatReopenStore() let r = chatSendCmdSync(.apiActivateChat)
let r = chatSendCmdSync(.apiActivateChat(restoreChat: true))
if case .cmdOk = r { return } if case .cmdOk = r { return }
logger.error("apiActivateChat error: \(String(describing: r))") logger.error("apiActivateChat error: \(String(describing: r))")
} }
@@ -365,13 +364,6 @@ func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId:
} }
} }
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
createChatItemErrorAlert(r)
return nil
}
private func sendMessageErrorAlert(_ r: ChatResponse) { private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("apiSendMessage error: \(String(describing: r))") logger.error("apiSendMessage error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -380,14 +372,6 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
) )
} }
private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(String(describing: r))"
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem }
@@ -412,14 +396,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
throw r throw r
} }
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
let r = chatSendCmdSync(.apiGetNtfToken) let r = chatSendCmdSync(.apiGetNtfToken)
switch r { switch r {
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer) case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil) case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
default: default:
logger.debug("apiGetNtfToken response: \(String(describing: r))") logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)")
return (nil, nil, .off, nil) return (nil, nil, .off)
} }
} }
@@ -596,15 +580,15 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
return nil return nil
} }
func apiAddContact(incognito: Bool) async -> ((String, PendingContactConnection)?, Alert?) { func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? {
guard let userId = ChatModel.shared.currentUser?.userId else { guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user") logger.error("apiAddContact: no current user")
return (nil, nil) return nil
} }
let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false) let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
if case let .invitation(_, connReqInvitation, connection) = r { return ((connReqInvitation, connection), nil) } if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) }
let alert = connectionErrorAlert(r) AlertManager.shared.showAlert(connectionErrorAlert(r))
return (nil, alert) return nil
} }
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? { func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
@@ -621,29 +605,27 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
throw r throw r
} }
func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? { func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq) let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert { if let alert = alert {
AlertManager.shared.showAlert(alert) AlertManager.shared.showAlert(alert)
return nil return nil
} else { } else {
return r return connReqType
} }
} }
func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else { guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user") logger.error("apiConnect: no current user")
return (nil, nil) return (nil, nil)
} }
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
let m = ChatModel.shared
switch r { switch r {
case let .sentConfirmation(_, connection): case .sentConfirmation: return (.invitation, nil)
return ((.invitation, connection), nil) case .sentInvitation: return (.contact, nil)
case let .sentInvitation(_, connection):
return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact): case let .contactAlreadyExists(_, contact):
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id } await MainActor.run { m.chatId = c.id }
} }
@@ -706,9 +688,6 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
} }
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
if case .direct = type, case .contactDeleted = r { return } if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return }
@@ -870,8 +849,8 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
} }
func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) { if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) {
await chatItemSimpleUpdate(user, chatItem) await chatItemSimpleUpdate(user, chatItem)
} }
} }
@@ -940,10 +919,8 @@ func findKnownRemoteCtrl() async throws {
try await sendCommandOkResp(.findKnownRemoteCtrl) try await sendCommandOkResp(.findKnownRemoteCtrl)
} }
func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { func confirmRemoteCtrl(_ rcId: Int64) async throws {
let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId))
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
throw r
} }
func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo {
@@ -1141,12 +1118,6 @@ func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMembe
throw r throw r
} }
func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember {
let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false)
if case let .memberBlockedForAllUser(_, _, member, _) = r { return member }
throw r
}
func leaveGroup(_ groupId: Int64) async { func leaveGroup(_ groupId: Int64) async {
do { do {
let groupInfo = try await apiLeaveGroup(groupId) let groupInfo = try await apiLeaveGroup(groupId)
@@ -1172,7 +1143,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact } .compactMap{ $0.chatInfo.contact }
.filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) } .filter{ !memberContactIds.contains($0.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() } .sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
} }
@@ -1236,11 +1207,9 @@ private func currentUserId(_ funcName: String) throws -> Int64 {
throw RuntimeError("\(funcName): no current user") throw RuntimeError("\(funcName): no current user")
} }
func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = nil, refreshInvitations: Bool = true, confirmMigrations: MigrationConfirmation? = nil) throws { func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true, confirmMigrations: MigrationConfirmation? = nil) throws {
logger.debug("initializeChat") logger.debug("initializeChat")
let m = ChatModel.shared let m = ChatModel.shared
m.ctrlInitInProgress = true
defer { m.ctrlInitInProgress = false }
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations) (m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations)
if m.chatDbStatus != .ok { return } if m.chatDbStatus != .ok { return }
// If we migrated successfully means previous re-encryption process on database level finished successfully too // If we migrated successfully means previous re-encryption process on database level finished successfully too
@@ -1257,43 +1226,10 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
onboardingStageDefault.set(.step1_SimpleXInfo) onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true) privacyDeliveryReceiptsSet.set(true)
m.onboardingStage = .step1_SimpleXInfo m.onboardingStage = .step1_SimpleXInfo
} else if confirmStart { } else if start {
showStartChatAfterRestartAlert { start in
do {
if start { AppChatState.shared.set(.active) }
try chatInitialized(start: start, refreshInvitations: refreshInvitations)
} catch let error {
logger.error("ChatInitialized error: \(error)")
}
}
} else {
try chatInitialized(start: start, refreshInvitations: refreshInvitations)
}
}
func showStartChatAfterRestartAlert(result: @escaping (_ start: Bool) -> Void) {
AlertManager.shared.showAlert(Alert(
title: Text("Start chat?"),
message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."),
primaryButton: .default(Text("Ok")) {
result(true)
},
secondaryButton: .cancel {
result(false)
}
))
}
private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
let m = ChatModel.shared
if m.currentUser == nil { return }
if start {
try startChat(refreshInvitations: refreshInvitations) try startChat(refreshInvitations: refreshInvitations)
} else { } else {
m.chatRunning = false m.chatRunning = false
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
m.onboardingStage = onboardingStageDefault.get()
} }
} }
@@ -1309,9 +1245,7 @@ func startChat(refreshInvitations: Bool = true) throws {
if (refreshInvitations) { if (refreshInvitations) {
try refreshCallInvitations() try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken { if let token = m.deviceToken {
registerToken(token: token) registerToken(token: token)
} }
@@ -1345,12 +1279,8 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
try getUserChatData() try getUserChatData()
} }
func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws { func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws {
let currentUser = if let userId = userId { let currentUser = try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
} else {
try apiGetActiveUser()
}
let users = try await listUsersAsync() let users = try await listUsersAsync()
await MainActor.run { await MainActor.run {
let m = ChatModel.shared let m = ChatModel.shared
@@ -1359,7 +1289,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws {
} }
try await getUserChatDataAsync() try await getUserChatDataAsync()
await MainActor.run { await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { if var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser invitation.user = currentUser
activateCall(invitation) activateCall(invitation)
} }
@@ -1375,21 +1305,14 @@ func getUserChatData() throws {
} }
private func getUserChatDataAsync() async throws { private func getUserChatDataAsync() async throws {
let m = ChatModel.shared let userAddress = try await apiGetUserAddressAsync()
if m.currentUser != nil { let chatItemTTL = try await getChatItemTTLAsync()
let userAddress = try await apiGetUserAddressAsync() let chats = try await apiGetChatsAsync()
let chatItemTTL = try await getChatItemTTLAsync() await MainActor.run {
let chats = try await apiGetChatsAsync() let m = ChatModel.shared
await MainActor.run { m.userAddress = userAddress
m.userAddress = userAddress m.chatItemTTL = chatItemTTL
m.chatItemTTL = chatItemTTL m.chats = chats.map { Chat.init($0) }
m.chats = chats.map { Chat.init($0) }
}
} else {
await MainActor.run {
m.userAddress = nil
m.chats = []
}
} }
} }
@@ -1437,6 +1360,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared let m = ChatModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { switch res {
case let .newContactConnection(user, connection):
if active(user) {
await MainActor.run {
m.updateContactConnection(connection)
}
}
case let .contactConnectionDeleted(user, connection):
if active(user) {
await MainActor.run {
m.removeChat(connection.id)
}
}
case let .contactDeletedByContact(user, contact): case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
await MainActor.run { await MainActor.run {
@@ -1548,7 +1483,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let file = cItem.autoReceiveFile() { if let file = cItem.autoReceiveFile() {
Task { Task {
await receiveFile(user: user, fileId: file.fileId, auto: true) await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true)
} }
} }
if cItem.showNotification { if cItem.showNotification {
@@ -1686,13 +1621,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertGroupMember(groupInfo, member) _ = m.upsertGroupMember(groupInfo, member)
} }
} }
case let .memberBlockedForAll(user, groupInfo, byMember: _, member: member, blocked: _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .newMemberContactReceivedInv(user, contact, _, _): case let .newMemberContactReceivedInv(user, contact, _, _):
if active(user) { if active(user) {
await MainActor.run { await MainActor.run {
@@ -1736,40 +1664,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
activateCall(invitation) activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _): case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in await withCall(contact) { call in
await MainActor.run { call.callState = .offerReceived
call.callState = .offerReceived call.peerMedia = callType.media
call.peerMedia = callType.media call.sharedKey = sharedKey
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers() let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)") logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))") logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer( m.callCommand = .offer(
offer: offer.rtcSession, offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates, iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey, media: callType.media, aesKey: sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
)) )
} }
case let .callAnswer(_, contact, answer): case let .callAnswer(_, contact, answer):
await withCall(contact) { call in await withCall(contact) { call in
await MainActor.run { call.callState = .answerReceived
call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
}
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
} }
case let .callExtraInfo(_, contact, extraInfo): case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in await withCall(contact) { _ in
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
} }
case let .callEnded(_, contact): case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation) CallController.shared.reportCallRemoteEnded(invitation: invitation)
} }
await withCall(contact) { call in await withCall(contact) { call in
await m.callCommand.processCommand(.end) m.callCommand = .end
CallController.shared.reportCallRemoteEnded(call: call) CallController.shared.reportCallRemoteEnded(call: call)
} }
case .chatSuspended: case .chatSuspended:
@@ -1790,17 +1714,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
await MainActor.run { await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
} }
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): case let .remoteCtrlFound(remoteCtrl):
await MainActor.run { // TODO multicast
if let sess = m.remoteCtrlSession, case .searching = sess.sessionState { logger.debug("\(String(describing: remoteCtrl))")
let state = UIRemoteCtrlSessionState.found(remoteCtrl: remoteCtrl, compatible: compatible)
m.remoteCtrlSession = RemoteCtrlSession(
ctrlAppInfo: ctrlAppInfo_,
appVersion: appVersion,
sessionState: state
)
}
}
case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode):
await MainActor.run { await MainActor.run {
let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode) let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode)
@@ -1815,23 +1731,16 @@ func processReceivedMsg(_ res: ChatResponse) async {
case .remoteCtrlStopped: case .remoteCtrlStopped:
// This delay is needed to cancel the session that fails on network failure, // This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet. // e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
await MainActor.run { switchToLocalSession()
m.remoteCtrlSession = nil
}
if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
switchToLocalSession()
}
}
} }
default: default:
logger.debug("unsupported event: \(res.responseType)") logger.debug("unsupported event: \(res.responseType)")
} }
func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async { func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
if let call = m.activeCall, call.contact.apiId == contact.apiId { if let call = m.activeCall, call.contact.apiId == contact.apiId {
await perform(call) await MainActor.run { perform(call) }
} else { } else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
} }
@@ -1861,9 +1770,7 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
let cItem = aChatItem.chatItem let cItem = aChatItem.chatItem
if active(user) { if active(user) {
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) { if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
if cItem.showNotification { NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
} }
} }
} }
+24 -87
View File
@@ -9,30 +9,27 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
let appSuspendTimeout: Int = 15 // seconds
let bgSuspendTimeout: Int = 5 // seconds let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds let terminationTimeout: Int = 3 // seconds
let activationDelay: TimeInterval = 1.5
let nseSuspendTimeout: TimeInterval = 5
private func _suspendChat(timeout: Int) { private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR // this is a redundant check to prevent logical errors, like the one fixed in this PR
let state = AppChatState.shared.value let state = appStateGroupDefault.get()
if !state.canSuspend { if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue)") logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
} else if ChatModel.ok { } else if ChatModel.ok {
AppChatState.shared.set(.suspending) appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000) apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended) let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
} else { } else {
AppChatState.shared.set(.suspended) appStateGroupDefault.set(.suspended)
} }
} }
@@ -44,16 +41,18 @@ func suspendChat() {
func suspendBgRefresh() { func suspendBgRefresh() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .bgRefresh = AppChatState.shared.value { if case .bgRefresh = appStateGroupDefault.get() {
_suspendChat(timeout: bgSuspendTimeout) _suspendChat(timeout: bgSuspendTimeout)
} }
} }
} }
private var terminating = false
func terminateChat() { func terminateChat() {
logger.debug("terminateChat") logger.debug("terminateChat")
suspendLockQueue.sync { suspendLockQueue.sync {
switch AppChatState.shared.value { switch appStateGroupDefault.get() {
case .suspending: case .suspending:
// suspend instantly if already suspending // suspend instantly if already suspending
_chatSuspended() _chatSuspended()
@@ -65,6 +64,7 @@ func terminateChat() {
case .stopped: case .stopped:
chatCloseStore() chatCloseStore()
default: default:
terminating = true
// the store will be closed in _chatSuspended when event is received // the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout) _suspendChat(timeout: terminationTimeout)
} }
@@ -73,7 +73,7 @@ func terminateChat() {
func chatSuspended() { func chatSuspended() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .suspending = AppChatState.shared.value { if case .suspending = appStateGroupDefault.get() {
_chatSuspended() _chatSuspended()
} }
} }
@@ -81,111 +81,48 @@ func chatSuspended() {
private func _chatSuspended() { private func _chatSuspended() {
logger.debug("_chatSuspended") logger.debug("_chatSuspended")
AppChatState.shared.set(.suspended) appStateGroupDefault.set(.suspended)
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
} }
chatCloseStore() if terminating {
} chatCloseStore()
func setAppState(_ appState: AppState) {
suspendLockQueue.sync {
AppChatState.shared.set(appState)
} }
} }
func activateChat(appState: AppState = .active) { func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat") logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync { suspendLockQueue.sync {
AppChatState.shared.set(appState) appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() } if ChatModel.ok { apiActivateChat() }
logger.debug("DEBUGGING: activateChat: after apiActivateChat") logger.debug("DEBUGGING: activateChat: after apiActivateChat")
} }
} }
func initChatAndMigrate(refreshInvitations: Bool = true) { func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
m.v3DBMigration = v3DBMigrationDefault.get()
if AppChatState.shared.value == .stopped && storeDBPassphraseGroupDefault.get() && kcDatabasePassword.get() != nil {
initialize(start: true, confirmStart: true)
} else {
initialize(start: true)
}
}
func initialize(start: Bool, confirmStart: Bool = false) {
do { do {
try initializeChat(start: m.v3DBMigration.startChat && start, confirmStart: m.v3DBMigration.startChat && confirmStart, refreshInvitations: refreshInvitations) m.v3DBMigration = v3DBMigrationDefault.get()
try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations)
} catch let error { } catch let error {
AlertManager.shared.showAlertMsg( fatalError("Failed to start or load chats: \(responseError(error))")
title: start ? "Error starting chat" : "Error opening chat",
message: "Please contact developers.\nError: \(responseError(error))"
)
} }
} }
} }
func startChatForCall() { func startChatAndActivate() {
logger.debug("DEBUGGING: startChatForCall") terminating = false
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatForCall: after ChatReceiver.shared.start")
}
if .active != AppChatState.shared.value {
logger.debug("DEBUGGING: startChatForCall: before activateChat")
activateChat()
logger.debug("DEBUGGING: startChatForCall: after activateChat")
}
}
func startChatAndActivate(_ completion: @escaping () -> Void) {
logger.debug("DEBUGGING: startChatAndActivate") logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start() ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
} }
if case .active = AppChatState.shared.value { if .active != appStateGroupDefault.get() {
completion()
} else if nseStateGroupDefault.get().inactive {
activate()
} else {
// setting app state to "activating" to notify NSE that it should suspend
setAppState(.activating)
waitNSESuspended(timeout: nseSuspendTimeout) { ok in
if !ok {
// if for some reason NSE failed to suspend,
// e.g., it crashed previously without setting its state to "suspended",
// set it to "suspended" state anyway, so that next time app
// does not have to wait when activating.
nseStateGroupDefault.set(.suspended)
}
if AppChatState.shared.value == .activating {
activate()
}
}
}
func activate() {
logger.debug("DEBUGGING: startChatAndActivate: before activateChat") logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
activateChat() activateChat()
completion()
logger.debug("DEBUGGING: startChatAndActivate: after activateChat") logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
} }
} }
// appStateGroupDefault must not be used in the app directly, only via this singleton
class AppChatState {
static let shared = AppChatState()
private var value_ = appStateGroupDefault.get()
var value: AppState {
value_
}
func set(_ state: AppState) {
appStateGroupDefault.set(state)
sendAppState(state)
value_ = state
}
}
+45 -37
View File
@@ -16,15 +16,17 @@ struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared @StateObject private var chatModel = ChatModel.shared
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var userAuthorized: Bool?
@State private var doAuthenticate = false
@State private var enteredBackground: TimeInterval? = nil
@State private var canConnectCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@State private var showInitializationView = false
init() { init() {
DispatchQueue.global(qos: .background).sync { hs_init(0, nil)
haskell_init()
// hs_init(0, nil)
}
UserDefaults.standard.register(defaults: appDefaults) UserDefaults.standard.register(defaults: appDefaults)
setGroupDefaults() setGroupDefaults()
registerGroupDefaults() registerGroupDefaults()
@@ -34,55 +36,53 @@ struct SimpleXApp: App {
} }
var body: some Scene { var body: some Scene {
WindowGroup { return WindowGroup {
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, ContentView(
// so that it's computed by the time view renders, and not on event after rendering doAuthenticate: $doAuthenticate,
ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) userAuthorized: $userAuthorized,
canConnectCall: $canConnectCall,
lastSuccessfulUnlock: $lastSuccessfulUnlock,
showInitializationView: $showInitializationView
)
.environmentObject(chatModel) .environmentObject(chatModel)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url chatModel.appOpenUrl = url
} }
.onAppear() { .onAppear() {
if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil { showInitializationView = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
initChatAndMigrate() initChatAndMigrate()
}
} }
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
switch (phase) { switch (phase) {
case .background: case .background:
// --- authentication
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
if chatModel.contentViewAccessAuthenticated {
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
}
chatModel.contentViewAccessAuthenticated = false
// authentication ---
if CallController.useCallKit() && chatModel.activeCall != nil { if CallController.useCallKit() && chatModel.activeCall != nil {
CallController.shared.shouldSuspendChat = true CallController.shared.shouldSuspendChat = true
} else { } else {
suspendChat() suspendChat()
BGManager.shared.schedule() BGManager.shared.schedule()
} }
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
canConnectCall = false
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
case .active: case .active:
CallController.shared.shouldSuspendChat = false CallController.shared.shouldSuspendChat = false
let appState = AppChatState.shared.value let appState = appStateGroupDefault.get()
startChatAndActivate()
if appState != .stopped { if appState.inactive && chatModel.chatRunning == true {
startChatAndActivate { updateChats()
if appState.inactive && chatModel.chatRunning == true { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateChats() updateCallInvitations()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateCallInvitations()
}
}
} }
} }
doAuthenticate = authenticationExpired()
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
default: default:
break break
} }
@@ -100,12 +100,12 @@ struct SimpleXApp: App {
if legacyDatabase, case .documents = dbContainerGroupDefault.get() { if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents) dbContainerGroupDefault.set(.documents)
setMigrationState(.offer) setMigrationState(.offer)
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath())*.db") logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db")
} else { } else {
dbContainerGroupDefault.set(.group) dbContainerGroupDefault.set(.group)
setMigrationState(.ready) setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db") logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present") logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
} }
} }
@@ -115,14 +115,22 @@ struct SimpleXApp: App {
} }
private func authenticationExpired() -> Bool { private func authenticationExpired() -> Bool {
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { if let enteredBackground = enteredBackground {
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
} else { } else {
return true return true
} }
} }
private func unlockedRecently() -> Bool {
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
} else {
return false
}
}
private func updateChats() { private func updateChats() {
do { do {
let chats = try apiGetChats() let chats = try apiGetChats()
+49 -99
View File
@@ -12,74 +12,67 @@ import SimpleXChat
struct ActiveCallView: View { struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var call: Call @ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil @State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil @State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil @State private var localRendererAspectRatio: CGFloat? = nil
@Binding var canConnectCall: Bool @Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .bottom) {
ZStack(alignment: .bottom) { if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil { GeometryReader { g in
GeometryReader { g in let width = g.size.width * 0.3
let width = g.size.width * 0.3 ZStack(alignment: .topTrailing) {
ZStack(alignment: .topTrailing) { CallViewRemote(client: client, activeCall: $activeCall)
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown) CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown) .cornerRadius(10)
.cornerRadius(10) .frame(width: width, height: width / (localRendererAspectRatio ?? 1))
.frame(width: width, height: width / (localRendererAspectRatio ?? 1)) .padding([.top, .trailing], 17)
.padding([.top, .trailing], 17)
ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001))
}
} }
} }
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) { }
ActiveCallOverlay(call: call, client: client) if let call = m.activeCall, let client = client {
} ActiveCallOverlay(call: call, client: client)
} }
} }
.allowsHitTesting(!m.activeCallViewIsCollapsed)
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true) AppDelegate.keepScreenOn(true)
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
hideKeyboard()
prevColorScheme = colorScheme
} }
.onChange(of: canConnectCall) { _ in .onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)") logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
createWebRTCClient() createWebRTCClient()
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
hideKeyboard()
}
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.background(m.activeCallViewIsCollapsed ? .clear : .black) .onChange(of: m.callCommand) { _ in sendCommandToClient()}
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it .background(.black)
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark) .preferredColorScheme(.dark)
} }
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { if client == nil && canConnectCall {
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
sendCommandToClient()
}
}
private func sendCommandToClient() {
if call == m.activeCall,
m.activeCall != nil,
let client = client,
let cmd = m.callCommand {
m.callCommand = nil
logger.debug("sendCallCommand: \(cmd.cmdType)")
Task { Task {
await m.callCommand.setClient(client) await client.sendCallCommand(command: cmd)
} }
} }
} }
@@ -87,8 +80,8 @@ struct ActiveCallView: View {
@MainActor @MainActor
private func processRtcMessage(msg: WVAPIMessage) { private func processRtcMessage(msg: WVAPIMessage) {
if call == m.activeCall, if call == m.activeCall,
let call = m.activeCall, let call = m.activeCall,
let client = client { let client = client {
logger.debug("ActiveCallView: response \(msg.resp.respType)") logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp { switch msg.resp {
case let .capabilities(capabilities): case let .capabilities(capabilities):
@@ -108,7 +101,7 @@ struct ActiveCallView: View {
Task { Task {
do { do {
try await apiSendCallOffer(call.contact, offer, iceCandidates, try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.localMedia, capabilities: capabilities) media: call.localMedia, capabilities: capabilities)
} catch { } catch {
logger.error("apiSendCallOffer \(responseError(error))") logger.error("apiSendCallOffer \(responseError(error))")
} }
@@ -140,15 +133,13 @@ struct ActiveCallView: View {
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
case .connected = callStatus { case .connected = callStatus {
call.direction == .outgoing call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil) : CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected call.callState = .connected
call.connectedAt = .now
} }
if state.connectionState == "closed" { if state.connectionState == "closed" {
closeCallView(client) closeCallView(client)
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
} }
Task { Task {
do { do {
@@ -160,7 +151,6 @@ struct ActiveCallView: View {
case let .connected(connectionInfo): case let .connected(connectionInfo):
call.callState = .connected call.callState = .connected
call.connectionInfo = connectionInfo call.connectionInfo = connectionInfo
call.connectedAt = .now
case .ended: case .ended:
closeCallView(client) closeCallView(client)
call.callState = .ended call.callState = .ended
@@ -174,15 +164,12 @@ struct ActiveCallView: View {
case .end: case .end:
closeCallView(client) closeCallView(client)
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
default: () default: ()
} }
case let .error(message): case let .error(message):
logger.debug("ActiveCallView: command error: \(message)") logger.debug("ActiveCallView: command error: \(message)")
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
case let .invalid(type): case let .invalid(type):
logger.debug("ActiveCallView: invalid response: \(type)") logger.debug("ActiveCallView: invalid response: \(type)")
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
} }
} }
} }
@@ -203,7 +190,7 @@ struct ActiveCallOverlay: View {
VStack { VStack {
switch call.localMedia { switch call.localMedia {
case .video: case .video:
videoCallInfoView(call) callInfoView(call, .leading)
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding() .padding()
@@ -230,25 +217,16 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
case .audio: case .audio:
ZStack(alignment: .topLeading) { VStack {
Button { ProfileImage(imageStr: call.contact.profile.image)
chatModel.activeCallViewIsCollapsed = true .scaledToFit()
} label: { .frame(width: 192, height: 192)
Label("Back", systemImage: "chevron.left") callInfoView(call, .center)
.padding()
.foregroundColor(.white.opacity(0.8))
}
VStack {
ProfileImage(imageStr: call.contact.profile.image)
.scaledToFit()
.frame(width: 192, height: 192)
audioCallInfoView(call)
}
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
} }
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
Spacer() Spacer()
@@ -266,52 +244,24 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
private func audioCallInfoView(_ call: Call) -> some View { private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
VStack { VStack {
Text(call.contact.chatViewName) Text(call.contact.chatViewName)
.lineLimit(1) .lineLimit(1)
.font(.title) .font(.title)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: alignment)
Group { Group {
Text(call.callState.text) Text(call.callState.text)
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")") Text("(") + Text(connInfo.text) + Text(")")
} }
} }
} }
.font(.subheadline) .font(.subheadline)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: alignment)
}
}
private func videoCallInfoView(_ call: Call) -> some View {
VStack {
Button {
chatModel.activeCallViewIsCollapsed = true
} label: {
HStack(alignment: .center, spacing: 16) {
Image(systemName: "chevron.left")
.resizable()
.frame(width: 10, height: 18)
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
Text("(") + Text(connInfo.text) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
+14 -27
View File
@@ -130,7 +130,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// The delay allows to accept the second call before suspending a chat // The delay allows to accept the second call before suspending a chat
// see `.onChange(of: scenePhase)` in SimpleXApp // see `.onChange(of: scenePhase)` in SimpleXApp
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat))") logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)")
if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true { if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
self?.shouldSuspendChat = false self?.shouldSuspendChat = false
suspendChat() suspendChat()
@@ -142,46 +142,33 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
@objc(pushRegistry:didUpdatePushCredentials:forType:) @objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)") logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)")
} }
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
logger.debug("CallController: did receive push with type \(type.rawValue)") logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)")
if type != .voIP { if type != .voIP {
completion() completion()
return return
} }
if AppChatState.shared.value == .stopped { logger.debug("CallController: initializing chat")
self.reportExpiredCall(payload: payload, completion)
return
}
if (!ChatModel.shared.chatInitialized) { if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat") initChatAndMigrate(refreshInvitations: false)
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
self.reportExpiredCall(payload: payload, completion)
return
}
} }
logger.debug("CallController: initialized chat") startChatAndActivate()
startChatForCall() shouldSuspendChat = true
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE // There are no invitations in the model, as it was processed by NSE
_ = try? justRefreshCallInvitations() _ = try? justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload // Extract the call information from the push notification payload
let m = ChatModel.shared let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String, if let contactId = payload.dictionaryPayload["contactId"] as? String,
let invitation = m.callInvitations[contactId] { let invitation = m.callInvitations[contactId] {
let update = self.cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
if let uuid = invitation.callkitUUID { if let uuid = invitation.callkitUUID {
logger.debug("CallController: report pushkit call via CallKit") logger.debug("CallController: report pushkit call via CallKit")
let update = self.cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil { if error != nil {
m.callInvitations.removeValue(forKey: contactId) m.callInvitations.removeValue(forKey: contactId)
} }
@@ -189,10 +176,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion() completion()
} }
} else { } else {
self.reportExpiredCall(update: update, completion) reportExpiredCall(update: update, completion)
} }
} else { } else {
self.reportExpiredCall(payload: payload, completion) reportExpiredCall(payload: payload, completion)
} }
} }
@@ -223,7 +210,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))") logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
if CallController.useCallKit(), let uuid = invitation.callkitUUID { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 { if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
@@ -363,7 +350,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) { private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
controller.request(CXTransaction(action: action)) { error in controller.request(CXTransaction(action: action)) { error in
if let error = error { if let error = error {
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)")
} else { } else {
logger.debug("CallController.requestTransaction requested transaction successfully") logger.debug("CallController.requestTransaction requested transaction successfully")
onSuccess() onSuccess()
+8 -14
View File
@@ -22,7 +22,7 @@ class CallManager {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callkitUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) } m.callCommand = .capabilities(media: call.localMedia)
return true return true
} }
return false return false
@@ -57,21 +57,19 @@ class CallManager {
m.activeCall = call m.activeCall = call
m.showCallView = true m.showCallView = true
Task { m.callCommand = .start(
await m.callCommand.processCommand(.start(
media: invitation.callType.media, media: invitation.callType.media,
aesKey: invitation.sharedKey, aesKey: invitation.sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
)) )
}
} }
} }
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) } m.callCommand = .media(media: media, enable: enable)
return true return true
} }
return false return false
@@ -92,19 +90,15 @@ class CallManager {
if case .ended = call.callState { if case .ended = call.callState {
logger.debug("CallManager.endCall: call ended") logger.debug("CallManager.endCall: call ended")
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false m.showCallView = false
completed() completed()
} else { } else {
logger.debug("CallManager.endCall: ending call...") logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end
m.activeCall = nil
m.showCallView = false
completed()
Task { Task {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false
completed()
}
do { do {
try await apiEndCall(call.contact) try await apiEndCall(call.contact)
} catch { } catch {
@@ -6,20 +6,14 @@
import SwiftUI import SwiftUI
import WebRTC import WebRTC
import SimpleXChat import SimpleXChat
import AVKit
struct CallViewRemote: UIViewRepresentable { struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?> var activeCall: Binding<WebRTCClient.Call?>
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool
@Binding var pipShown: Bool
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
self.client = client self.client = client
self.activeCall = activeCall self.activeCall = activeCall
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
@@ -29,120 +23,12 @@ struct CallViewRemote: UIViewRepresentable {
remoteRenderer.videoContentMode = .scaleAspectFill remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer) client.addRemoteRenderer(call, remoteRenderer)
addSubviewAndResize(remoteRenderer, into: view) addSubviewAndResize(remoteRenderer, into: view)
if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
}
} }
return view return view
} }
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteRenderer.videoContentMode = .scaleAspectFill
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController
)
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
pipController.canStartPictureInPictureAutomaticallyFromInline = true
pipController.delegate = context.coordinator
context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in
if show {
client.addRemoteRenderer(call, pipRemoteRenderer)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
activeCallViewIsCollapsed = false
}
}
}
context.coordinator.didShowHide = { show in
if show {
remoteRenderer.isHidden = true
} else {
client.removeRemoteRenderer(call, pipRemoteRenderer)
remoteRenderer.isHidden = false
}
pipShown = show
}
DispatchQueue.main.async {
enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ {
if enable {
pipController.startPictureInPicture()
} else {
pipController.stopPictureInPicture()
}
}
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote") logger.debug("CallView.updateUIView remote")
DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed)
}
}
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in }
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true)
}
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(true)
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
logger.error("PiP failed to start: \(error.localizedDescription)")
}
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(false)
}
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(false)
}
deinit {
pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil
pipController?.delegate = nil
pipController = nil
}
}
class SampleBufferVideoCallView: UIView {
override class var layerClass: AnyClass {
get { return AVSampleBufferDisplayLayer.self }
}
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
return layer as! AVSampleBufferDisplayLayer
}
} }
} }
@@ -150,14 +36,11 @@ struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?> var activeCall: Binding<WebRTCClient.Call?>
var localRendererAspectRatio: Binding<CGFloat?> var localRendererAspectRatio: Binding<CGFloat?>
@State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
self.client = client self.client = client
self.activeCall = activeCall self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
@@ -167,18 +50,12 @@ struct CallViewLocal: UIViewRepresentable {
client.addLocalRenderer(call, localRenderer) client.addLocalRenderer(call, localRenderer)
client.startCaptureLocalVideo(call) client.startCaptureLocalVideo(call)
addSubviewAndResize(localRenderer, into: view) addSubviewAndResize(localRenderer, into: view)
DispatchQueue.main.async {
pipStateChanged = { shown in
localRenderer.isHidden = shown
}
}
} }
return view return view
} }
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local") logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown)
} }
} }
+14 -46
View File
@@ -28,7 +28,6 @@ class Call: ObservableObject, Equatable {
@Published var speakerEnabled = false @Published var speakerEnabled = false
@Published var videoEnabled: Bool @Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo? @Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init( init(
direction: CallDirection, direction: CallDirection,
@@ -60,7 +59,6 @@ class Call: ObservableObject, Equatable {
} }
} }
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
} }
enum CallDirection { enum CallDirection {
@@ -337,50 +335,6 @@ extension WCallResponse: Encodable {
} }
} }
actor WebRTCCommandProcessor {
private var client: WebRTCClient? = nil
private var commands: [WCallCommand] = []
private var running: Bool = false
func setClient(_ client: WebRTCClient?) async {
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
self.client = client
if client != nil {
await processAllCommands()
} else {
commands.removeAll()
}
}
func processCommand(_ c: WCallCommand) async {
// logger.debug("WebRTC: process command \(c.cmdType)")
commands.append(c)
if !running && client != nil {
await processAllCommands()
}
}
func processAllCommands() async {
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
if let client = client {
running = true
while let c = commands.first, shouldRunCommand(client, c) {
commands.remove(at: 0)
await client.sendCallCommand(command: c)
logger.debug("WebRTC: processed cmd \(c.cmdType)")
}
running = false
}
}
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
default: client.activeCall.wrappedValue != nil
}
}
}
struct ConnectionState: Codable, Equatable { struct ConnectionState: Codable, Equatable {
var connectionState: String var connectionState: String
var iceConnectionState: String var iceConnectionState: String
@@ -404,12 +358,26 @@ struct ConnectionInfo: Codable, Equatable {
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)" return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
} }
} }
var protocolText: String {
let unknown = NSLocalizedString("unknown", comment: "connection info")
let local = localCandidate?.protocol?.uppercased() ?? unknown
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
let localText = localRelay == local || localCandidate?.relayProtocol == nil
? local
: "\(local) (\(localRelay))"
return local == remote
? localText
: "\(localText) / \(remote)"
}
} }
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable { struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType? var candidateType: RTCIceCandidateType?
var `protocol`: String? var `protocol`: String?
var relayProtocol: String?
var sdpMid: String? var sdpMid: String?
var sdpMLineIndex: Int? var sdpMLineIndex: Int?
var candidate: String var candidate: String
+78 -124
View File
@@ -18,11 +18,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}() }()
private static let ivTagBytes: Int = 28 private static let ivTagBytes: Int = 28
private static let enableEncryption: Bool = true private static let enableEncryption: Bool = true
private var chat_ctrl = getChatCtrl()
struct Call { struct Call {
var connection: RTCPeerConnection var connection: RTCPeerConnection
var iceCandidates: IceCandidates var iceCandidates: [RTCIceCandidate]
var localMedia: CallMediaType var localMedia: CallMediaType
var localCamera: RTCVideoCapturer? var localCamera: RTCVideoCapturer?
var localVideoSource: RTCVideoSource? var localVideoSource: RTCVideoSource?
@@ -34,24 +33,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameDecryptor: RTCFrameDecryptor? var frameDecryptor: RTCFrameDecryptor?
} }
actor IceCandidates {
private var candidates: [RTCIceCandidate] = []
func getAndClear() async -> [RTCIceCandidate] {
let cs = candidates
candidates = []
return cs
}
func append(_ c: RTCIceCandidate) async {
candidates.append(c)
}
}
private let rtcAudioSession = RTCAudioSession.sharedInstance() private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "audio") private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
var activeCall: Binding<Call?> private var activeCall: Binding<Call?>
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
@available(*, unavailable) @available(*, unavailable)
@@ -75,7 +60,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
] ]
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self connection.delegate = self
createAudioSender(connection) createAudioSender(connection)
@@ -102,7 +87,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
return Call( return Call(
connection: connection, connection: connection,
iceCandidates: IceCandidates(), iceCandidates: remoteIceCandidates,
localMedia: mediaType, localMedia: mediaType,
localCamera: localCamera, localCamera: localCamera,
localVideoSource: localVideoSource, localVideoSource: localVideoSource,
@@ -159,18 +144,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall.wrappedValue != nil { endCall() } if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay)
activeCall.wrappedValue = call activeCall.wrappedValue = call
let (offer, error) = await call.connection.offer() call.connection.offer { answer in
if let offer = offer { Task {
resp = .offer( let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))), if gotCandidates {
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())), await self.sendCallResponse(.init(
capabilities: CallCapabilities(encryption: encryption) corrId: nil,
) resp: .offer(
self.waitForMoreIceCandidates() offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
} else { iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")") capabilities: CallCapabilities(encryption: encryption)
),
command: command)
)
} else {
self.endCall()
}
}
} }
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall.wrappedValue != nil { if activeCall.wrappedValue != nil {
@@ -179,21 +172,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "accept: encryption is not supported") resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
activeCall.wrappedValue = call activeCall.wrappedValue = call
let pc = call.connection let pc = call.connection
if let type = offer.type, let sdp = offer.sdp { if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
let (answer, error) = await pc.answer() pc.answer { answer in
if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates) self.addIceCandidates(pc, remoteIceCandidates)
resp = .answer( // Task {
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), // try? await Task.sleep(nanoseconds: 32_000 * 1000000)
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())) Task {
) await self.sendCallResponse(.init(
self.waitForMoreIceCandidates() corrId: nil,
} else { resp: .answer(
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")") answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
),
command: command)
)
}
// }
} }
} else { } else {
resp = .error(message: "accept: remote description is not set") resp = .error(message: "accept: remote description is not set")
@@ -236,7 +234,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .ok resp = .ok
} }
case .end: case .end:
// TODO possibly, endCall should be called before returning .ok
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command)) await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
endCall() endCall()
} }
@@ -245,33 +242,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates
}
func waitForMoreIceCandidates() {
Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates)
}
}
}
}
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
await self.sendCallResponse(.init(
corrId: nil,
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
command: nil)
)
}
func enableMedia(_ media: CallMediaType, _ enable: Bool) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
@@ -309,7 +279,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0 let isKeyFrame = unencrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
} else { } else {
return nil return nil
@@ -331,10 +301,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
activeCall.remoteStream?.add(renderer) activeCall.remoteStream?.add(renderer)
} }
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
activeCall.remoteStream?.remove(renderer)
}
func startCaptureLocalVideo(_ activeCall: Call) { func startCaptureLocalVideo(_ activeCall: Call) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
guard guard
@@ -414,7 +380,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
guard let call = activeCall.wrappedValue else { return } guard let call = activeCall.wrappedValue else { return }
logger.debug("WebRTCClient: ending the call") logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil activeCall.wrappedValue = nil
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
call.connection.close() call.connection.close()
call.connection.delegate = nil call.connection.delegate = nil
call.frameEncryptor?.delegate = nil call.frameEncryptor?.delegate = nil
@@ -422,13 +387,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
audioSessionToDefaults() audioSessionToDefaults()
} }
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async { func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
var t: UInt64 = 0 let startedAt = DispatchTime.now()
repeat { while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000) guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
t += stepMs }
await action() return success()
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@@ -441,33 +405,25 @@ extension WebRTC.RTCPeerConnection {
optionalConstraints: nil) optionalConstraints: nil)
} }
func offer() async -> (RTCSessionDescription?, Error?) { func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
await withCheckedContinuation { cont in offer(for: mediaConstraints()) { (sdp, error) in
offer(for: mediaConstraints()) { (sdp, error) in guard let sdp = sdp else {
self.processSDP(cont, sdp, error) return
} }
}
}
func answer() async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
answer(for: mediaConstraints()) { (sdp, error) in
self.processSDP(cont, sdp, error)
}
}
}
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
if let sdp = sdp {
self.setLocalDescription(sdp, completionHandler: { (error) in self.setLocalDescription(sdp, completionHandler: { (error) in
if let error = error { completion(sdp)
cont.resume(returning: (nil, error)) })
} else { }
cont.resume(returning: (sdp, nil)) }
}
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
answer(for: mediaConstraints()) { (sdp, error) in
guard let sdp = sdp else {
return
}
self.setLocalDescription(sdp, completionHandler: { (error) in
completion(sdp)
}) })
} else {
cont.resume(returning: (nil, error))
} }
} }
} }
@@ -523,7 +479,6 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
default: enableSpeaker = false default: enableSpeaker = false
} }
setSpeakerEnabledAndConfigureSession(enableSpeaker) setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall() case .disconnected, .failed: endCall()
default: do {} default: do {}
} }
@@ -536,9 +491,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)") // logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task { activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
}
} }
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) { func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
@@ -553,9 +506,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
lastReceivedMs lastDataReceivedMs: Int32, lastReceivedMs lastDataReceivedMs: Int32,
changeReason reason: String) { changeReason reason: String) {
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)") // logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
sendConnectedEvent(connection, local: local, remote: remote)
} }
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) { func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) {
connection.statistics { (stats: RTCStatisticsReport) in connection.statistics { (stats: RTCStatisticsReport) in
stats.statistics.values.forEach { stat in stats.statistics.values.forEach { stat in
// logger.debug("Stat \(stat.debugDescription)") // logger.debug("Stat \(stat.debugDescription)")
@@ -563,25 +517,24 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
let localId = stat.values["localCandidateId"] as? String, let localId = stat.values["localCandidateId"] as? String,
let remoteId = stat.values["remoteCandidateId"] as? String, let remoteId = stat.values["remoteCandidateId"] as? String,
let localStats = stats.statistics[localId], let localStats = stats.statistics[localId],
let remoteStats = stats.statistics[remoteId] let remoteStats = stats.statistics[remoteId],
local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") &&
remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))")
{ {
Task { Task {
await self.sendCallResponse(.init( await self.sendCallResponse(.init(
corrId: nil, corrId: nil,
resp: .connected(connectionInfo: ConnectionInfo( resp: .connected(connectionInfo: ConnectionInfo(
localCandidate: RTCIceCandidate( localCandidate: local.toCandidate(
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
protocol: localStats.values["protocol"] as? String, localStats.values["protocol"] as? String,
sdpMid: nil, localStats.values["relayProtocol"] as? String
sdpMLineIndex: nil,
candidate: ""
), ),
remoteCandidate: RTCIceCandidate( remoteCandidate: remote.toCandidate(
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
protocol: remoteStats.values["protocol"] as? String, remoteStats.values["protocol"] as? String,
sdpMid: nil, remoteStats.values["relayProtocol"] as? String
sdpMLineIndex: nil, ))),
candidate: ""))),
command: nil) command: nil)
) )
} }
@@ -681,10 +634,11 @@ extension RTCIceCandidate {
} }
extension WebRTC.RTCIceCandidate { extension WebRTC.RTCIceCandidate {
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate { func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
RTCIceCandidate( RTCIceCandidate(
candidateType: candidateType, candidateType: candidateType,
protocol: `protocol`, protocol: `protocol`,
relayProtocol: relayProtocol,
sdpMid: sdpMid, sdpMid: sdpMid,
sdpMLineIndex: Int(sdpMLineIndex), sdpMLineIndex: Int(sdpMLineIndex),
candidate: sdp candidate: sdp
@@ -52,7 +52,7 @@ struct CIFileView: View {
private var itemInteractive: Bool { private var itemInteractive: Bool {
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local case .sndStored: return false
case .sndTransfer: return false case .sndTransfer: return false
case .sndComplete: return false case .sndComplete: return false
case .sndCancelled: return false case .sndCancelled: return false
@@ -85,7 +85,8 @@ struct CIFileView: View {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId) let encrypted = privacyEncryptLocalFilesGroupDefault.get()
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
} }
} }
} else { } else {
@@ -107,18 +108,12 @@ struct CIFileView: View {
title: "Waiting for file", title: "Waiting for file",
message: "File will be received when your contact is online, please wait or check later!" message: "File will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvComplete: case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete") logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
default: break default: break
} }
} }
@@ -131,13 +126,11 @@ struct CIFileView: View {
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: fileIcon("doc.fill") case .smp: fileIcon("doc.fill")
case .local: fileIcon("doc.fill")
} }
case let .sndTransfer(sndProgress, sndTotal): case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal) case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView() case .smp: progressView()
case .local: EmptyView()
} }
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
@@ -29,9 +29,6 @@ struct CIImageView: View {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
} }
.onTapGesture { showFullScreenImage = true } .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let data = Data(base64Encoded: dropImagePrefix(image)), } else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) { let uiImage = UIImage(data: data) {
imageView(uiImage) imageView(uiImage)
@@ -41,7 +38,7 @@ struct CIImageView: View {
case .rcvInvitation: case .rcvInvitation:
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId) await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
} }
} }
case .rcvAccepted: case .rcvAccepted:
@@ -56,7 +53,6 @@ struct CIImageView: View {
title: "Waiting for image", title: "Waiting for image",
message: "Image will be received when your contact is online, please wait or check later!" message: "Image will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
@@ -94,7 +90,6 @@ struct CIImageView: View {
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: EmptyView() case .smp: EmptyView()
case .local: EmptyView()
} }
case .sndTransfer: progressView() case .sndTransfer: progressView()
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
@@ -26,8 +26,6 @@ struct CIVideoView: View {
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer? @State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@State private var showFullScreenPlayer = false @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@@ -41,12 +39,8 @@ struct CIVideoView: View {
self._videoWidth = videoWidth self._videoWidth = videoWidth
self.scrollProxy = scrollProxy self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) { if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet() self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
self._urlDecrypted = State(initialValue: decrypted) self._fullPlayer = State(initialValue: AVPlayer(url: url))
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url) self._url = State(initialValue: url)
} }
if let data = Data(base64Encoded: dropImagePrefix(image)), if let data = Data(base64Encoded: dropImagePrefix(image)),
@@ -59,10 +53,8 @@ struct CIVideoView: View {
let file = chatItem.file let file = chatItem.file
ZStack { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { if let file = file, let preview = preview, let player = player, let url = url {
videoView(player, decrypted, file, preview, duration) videoView(player, url, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let data = Data(base64Encoded: dropImagePrefix(image)), } else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) { let uiImage = UIImage(data: data) {
imageView(uiImage) imageView(uiImage)
@@ -70,7 +62,7 @@ struct CIVideoView: View {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation: case .rcvInvitation:
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: case .xftp:
@@ -83,7 +75,6 @@ struct CIVideoView: View {
title: "Waiting for video", title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!" message: "Video will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
@@ -97,7 +88,7 @@ struct CIVideoView: View {
} }
if let file = file, case .rcvInvitation = file.fileStatus { if let file = file, case .rcvInvitation = file.fileStatus {
Button { Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
} label: { } label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
} }
@@ -105,49 +96,12 @@ struct CIVideoView: View {
} }
} }
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !decryptionInProgress {
Button {
decrypt(file: file) {
if let decrypted = urlDecrypted {
videoPlaying = true
player?.play()
}
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
}
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View { private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w } DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
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
VideoPlayerView(player: player, url: url, showControls: false) VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width) .frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: m.stopPreviousRecPlay) { playingUrl in .onChange(of: m.stopPreviousRecPlay) { playingUrl in
@@ -171,9 +125,6 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying { if !videoPlaying {
Button { Button {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
@@ -208,16 +159,6 @@ struct CIVideoView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 12, height: 12)
.tint(color)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
private func durationProgress() -> some View { private func durationProgress() -> some View {
HStack { HStack {
Text("\(durationText(videoPlaying ? progress : duration))") Text("\(durationText(videoPlaying ? progress : duration))")
@@ -261,13 +202,11 @@ struct CIVideoView: View {
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: EmptyView() case .smp: EmptyView()
case .local: EmptyView()
} }
case let .sndTransfer(sndProgress, sndTotal): case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal) case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView() case .smp: progressView()
case .local: EmptyView()
} }
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
@@ -318,10 +257,10 @@ struct CIVideoView: View {
} }
// TODO encrypt: where file size is checked? // TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) { private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user, file.fileId, false) await receiveFile(user, file.fileId, encrypted, false)
} }
} }
} }
@@ -384,22 +323,6 @@ struct CIVideoView: View {
} }
} }
private func decrypt(file: CIFile, completed: (() -> Void)? = nil) {
if decryptionInProgress { return }
decryptionInProgress = true
Task {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = true
completed?()
}
}
}
private func addObserver(_ player: AVPlayer, _ url: URL) { private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem { if let item = player.currentItem {
@@ -221,7 +221,7 @@ struct VoiceMessagePlayer: View {
Button { Button {
Task { Task {
if let user = chatModel.currentUser { if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId) await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
} }
} }
} label: { } label: {
@@ -9,8 +9,6 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
let notesChatColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.21)
let notesChatColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.19)
let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11) private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
@@ -30,9 +28,7 @@ struct FramedItemView: View {
@State var metaColor = Color.secondary @State var metaColor = Color.secondary
@State var showFullScreenImage = false @State var showFullScreenImage = false
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@State private var showSecrets = false
@State private var showQuoteSecrets = false
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
@@ -46,9 +42,7 @@ struct FramedItemView: View {
framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic())
case .blocked: case .blocked:
framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic()) framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic())
case .blockedByAdmin: default:
framedItemHeader(icon: "hand.raised", caption: Text("blocked by admin").italic())
case .deleted:
framedItemHeader(icon: "trash", caption: Text("marked deleted").italic()) framedItemHeader(icon: "trash", caption: Text("marked deleted").italic())
} }
} else if chatItem.meta.isLive { } else if chatItem.meta.isLive {
@@ -258,12 +252,10 @@ struct FramedItemView: View {
} }
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
toggleSecrets(qi.formattedText, $showQuoteSecrets, MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText)
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets) .lineLimit(lines)
.lineLimit(lines) .font(.subheadline)
.font(.subheadline) .padding(.bottom, 6)
.padding(.bottom, 6)
)
} }
private func ciQuoteIconView(_ image: String) -> some View { private func ciQuoteIconView(_ image: String) -> some View {
@@ -286,15 +278,13 @@ struct FramedItemView: View {
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text) let rtl = isRightToLeft(text)
let ft = text == "" ? [] : ci.formattedText let v = MsgContentView(
let v = toggleSecrets(ft, $showSecrets, MsgContentView(
chat: chat, chat: chat,
text: text, text: text,
formattedText: ft, formattedText: text == "" ? [] : ci.formattedText,
meta: ci.meta, meta: ci.meta,
rightToLeft: rtl, rightToLeft: rtl
showSecrets: showSecrets )
))
.multilineTextAlignment(rtl ? .trailing : .leading) .multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -308,7 +298,7 @@ struct FramedItemView: View {
v v
} }
} }
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth()) .overlay(DetermineWidth())
@@ -328,14 +318,6 @@ struct FramedItemView: View {
} }
} }
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
if let ft = ft, ft.contains(where: { $0.isSecret }) {
v.onTapGesture { showSecrets.wrappedValue.toggle() }
} else {
v
}
}
func isRightToLeft(_ s: String) -> Bool { func isRightToLeft(_ s: String) -> Bool {
if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) { if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) {
return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft
@@ -33,7 +33,6 @@ struct MarkedDeletedItemView: View {
var i = m.getChatItemIndex(chatItem) { var i = m.getChatItemIndex(chatItem) {
var moderated = 0 var moderated = 0
var blocked = 0 var blocked = 0
var blockedByAdmin = 0
var deleted = 0 var deleted = 0
var moderatedBy: Set<String> = [] var moderatedBy: Set<String> = []
while i < m.reversedChatItems.count, while i < m.reversedChatItems.count,
@@ -45,19 +44,16 @@ struct MarkedDeletedItemView: View {
moderated += 1 moderated += 1
moderatedBy.insert(byGroupMember.displayName) moderatedBy.insert(byGroupMember.displayName)
case .blocked: blocked += 1 case .blocked: blocked += 1
case .blockedByAdmin: blockedByAdmin += 1
case .deleted: deleted += 1 case .deleted: deleted += 1
} }
i += 1 i += 1
} }
let total = moderated + blocked + blockedByAdmin + deleted let total = moderated + blocked + deleted
return total <= 1 return total <= 1
? markedDeletedText ? markedDeletedText
: total == moderated : total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))" ? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blockedByAdmin : total == blocked
? "\(total) messages blocked by admin"
: total == blocked + blockedByAdmin
? "\(total) messages blocked" ? "\(total) messages blocked"
: "\(total) messages marked deleted" : "\(total) messages marked deleted"
} else { } else {
@@ -65,14 +61,11 @@ struct MarkedDeletedItemView: View {
} }
} }
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey { var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted { switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked" case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin" default: "marked deleted"
case .deleted, nil: "marked deleted"
} }
} }
} }
@@ -9,7 +9,7 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let noTyping = Text(" ") private let noTyping = Text(" ")
@@ -31,7 +31,6 @@ struct MsgContentView: View {
var sender: String? = nil var sender: String? = nil
var meta: CIMeta? = nil var meta: CIMeta? = nil
var rightToLeft = false var rightToLeft = false
var showSecrets: Bool
@State private var typingIdx = 0 @State private var typingIdx = 0
@State private var timer: Timer? @State private var timer: Timer?
@@ -63,7 +62,7 @@ struct MsgContentView: View {
} }
private func msgContentView() -> Text { private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets) var v = messageText(text, formattedText, sender)
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
v = v + typingIndicator(mt.recent) v = v + typingIndicator(mt.recent)
@@ -85,14 +84,14 @@ struct MsgContentView: View {
} }
} }
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
let s = text let s = text
var res: Text var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 { if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets) res = formatText(ft[0], preview)
var i = 1 var i = 1
while i < ft.count { while i < ft.count {
res = res + formatText(ft[i], preview, showSecret: showSecrets) res = res + formatText(ft[i], preview)
i = i + 1 i = i + 1
} }
} else { } else {
@@ -111,7 +110,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
} }
} }
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text { private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
let t = ft.text let t = ft.text
if let f = ft.format { if let f = ft.format {
switch (f) { switch (f) {
@@ -119,13 +118,7 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
case .italic: return Text(t).italic() case .italic: return Text(t).italic()
case .strikeThrough: return Text(t).strikethrough() case .strikeThrough: return Text(t).strikethrough()
case .snippet: return Text(t).font(.body.monospaced()) case .snippet: return Text(t).font(.body.monospaced())
case .secret: return case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
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 let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "") case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, smpHosts): case let .simplexLink(linkType, simplexUri, smpHosts):
@@ -151,7 +144,7 @@ private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: Stri
]))).underline() ]))).underline()
} }
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))" linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
} }
@@ -163,8 +156,7 @@ struct MsgContentView_Previews: PreviewProvider {
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
meta: chatItem.meta, meta: chatItem.meta
showSecrets: false
) )
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
@@ -53,9 +53,7 @@ struct ChatItemInfoView: View {
} }
private var title: String { private var title: String {
ci.localNote ci.chatDir.sent
? NSLocalizedString("Saved message", comment: "message info title")
: ci.chatDir.sent
? NSLocalizedString("Sent message", comment: "message info title") ? NSLocalizedString("Sent message", comment: "message info title")
: NSLocalizedString("Received message", comment: "message info title") : NSLocalizedString("Received message", comment: "message info title")
} }
@@ -112,11 +110,7 @@ struct ChatItemInfoView: View {
.bold() .bold()
.padding(.bottom) .padding(.bottom)
if ci.localNote { infoRow("Sent at", localTimestamp(meta.itemTs))
infoRow("Created at", localTimestamp(meta.itemTs))
} else {
infoRow("Sent at", localTimestamp(meta.itemTs))
}
if !ci.chatDir.sent { if !ci.chatDir.sent {
infoRow("Received at", localTimestamp(meta.createdAt)) infoRow("Received at", localTimestamp(meta.createdAt))
} }
@@ -174,6 +168,7 @@ struct ChatItemInfoView: View {
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
.allowsHitTesting(false)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(ci, colorScheme)) .background(chatItemFrameColor(ci, colorScheme))
@@ -203,7 +198,7 @@ struct ChatItemInfoView: View {
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> 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) messageText(text, formattedText, sender)
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
@@ -211,17 +206,6 @@ struct ChatItemInfoView: View {
} }
} }
private struct TextBubble: View {
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
@State private var showSecrets = false
var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
}
}
@ViewBuilder 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
@@ -243,6 +227,7 @@ struct ChatItemInfoView: View {
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
textBubble(qi.text, qi.formattedText, qi.getSender(nil)) textBubble(qi.text, qi.formattedText, qi.getSender(nil))
.allowsHitTesting(false)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, colorScheme)) .background(quotedMsgFrameColor(qi, colorScheme))
@@ -356,12 +341,7 @@ struct ChatItemInfoView: View {
private func itemInfoShareText() -> String { private func itemInfoShareText() -> String {
let meta = ci.meta let meta = ci.meta
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""] var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
shareText += [String.localizedStringWithFormat( shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
ci.localNote
? NSLocalizedString("Created at: %@", comment: "copied message info")
: NSLocalizedString("Sent at: %@", comment: "copied message info"),
localTimestamp(meta.itemTs))
]
if !ci.chatDir.sent { if !ci.chatDir.sent {
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))] shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
} }
@@ -109,7 +109,6 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case .sndModerated: deletedItemView() case .sndModerated: deletedItemView()
case .rcvModerated: deletedItemView() case .rcvModerated: deletedItemView()
case .rcvBlocked: deletedItemView()
case let .invalidJSON(json): CIInvalidJSONView(json: json) case let .invalidJSON(json): CIInvalidJSONView(json: json)
} }
} }
+17 -45
View File
@@ -151,25 +151,18 @@ struct ChatView: View {
) )
) )
} }
} else if case .local = cInfo {
ChatInfoToolbar(chat: chat)
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
switch cInfo { switch cInfo {
case let .direct(contact): case let .direct(contact):
HStack { HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser if contact.allowsFeature(.calls) {
if callsPrefEnabled { callButton(contact, .audio, imageName: "phone")
if chatModel.activeCall == nil { .disabled(!contact.ready || !contact.active)
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
} }
Menu { Menu {
if callsPrefEnabled && chatModel.activeCall == nil { if contact.allowsFeature(.calls) {
Button { Button {
CallController.shared.startCall(contact, .video) CallController.shared.startCall(contact, .video)
} label: { } label: {
@@ -212,8 +205,6 @@ struct ChatView: View {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
} }
} }
case .local:
searchButton()
default: default:
EmptyView() EmptyView()
} }
@@ -259,8 +250,8 @@ struct ChatView: View {
} }
private func searchToolbar() -> some View { private func searchToolbar() -> some View {
HStack(spacing: 12) { HStack {
HStack(spacing: 4) { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText) TextField("Search", text: $searchText)
.focused($searchFocussed) .focused($searchFocussed)
@@ -273,9 +264,9 @@ struct ChatView: View {
Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1) Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
} }
} }
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7)) .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.background(Color(.tertiarySystemFill)) .background(Color(.secondarySystemBackground))
.cornerRadius(10.0) .cornerRadius(10.0)
Button ("Cancel") { Button ("Cancel") {
@@ -426,19 +417,7 @@ struct ChatView: View {
Image(systemName: imageName) Image(systemName: imageName)
} }
} }
private func endCallButton(_ call: Call) -> some View {
Button {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
} label: {
Image(systemName: "phone.down.fill").tint(.red)
}
}
private func searchButton() -> some View { private func searchButton() -> some View {
Button { Button {
searchMode = true searchMode = true
@@ -657,7 +636,7 @@ struct ChatView: View {
Button("Delete for me", role: .destructive) { Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal) deleteMessage(.cidmInternal)
} }
if let di = deletingItem, di.meta.editable && !di.localNote { if let di = deletingItem, di.meta.editable {
Button(broadcastDeleteButtonText, role: .destructive) { Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast) deleteMessage(.cidmBroadcast)
} }
@@ -741,17 +720,12 @@ struct ChatView: View {
} }
menu.append(rm) menu.append(rm)
} }
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote { if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction(ci)) menu.append(replyUIAction(ci))
} }
let fileSource = getLoadedFileSource(ci.file) menu.append(shareUIAction(ci))
let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false } menu.append(copyUIAction(ci))
let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists) if let fileSource = getLoadedFileSource(ci.file) {
if copyAndShareAllowed {
menu.append(shareUIAction(ci))
menu.append(copyUIAction(ci))
}
if let fileSource = fileSource, fileExists {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil { if image.imageData != nil {
menu.append(saveFileAction(fileSource)) menu.append(saveFileAction(fileSource))
@@ -765,15 +739,13 @@ struct ChatView: View {
if ci.meta.editable && !mc.isVoice && !live { if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction(ci)) menu.append(editAction(ci))
} }
if !ci.isLiveDummy { menu.append(viewInfoUIAction(ci))
menu.append(viewInfoUIAction(ci))
}
if revealed { if revealed {
menu.append(hideUIAction()) menu.append(hideUIAction())
} }
if ci.meta.itemDeleted == nil && !ci.localNote, if ci.meta.itemDeleted == nil,
let file = ci.file, let file = ci.file,
let cancelAction = file.cancelAction { let cancelAction = file.cancelAction {
menu.append(cancelFileUIAction(file.fileId, cancelAction)) menu.append(cancelFileUIAction(file.fileId, cancelAction))
} }
if !live || !ci.meta.isLive { if !live || !ci.meta.isLive {
@@ -104,7 +104,7 @@ struct ComposeState {
var sendEnabled: Bool { var sendEnabled: Bool {
switch preview { switch preview {
case let .mediaPreviews(media): return !media.isEmpty case .mediaPreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true case .filePreview: return true
default: return !message.isEmpty || liveMessage != nil default: return !message.isEmpty || liveMessage != nil
@@ -295,7 +295,7 @@ struct ComposeView: View {
sendMessage(ttl: ttl) sendMessage(ttl: ttl)
resetLinkPreview() resetLinkPreview()
}, },
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil, sendLiveMessage: sendLiveMessage,
updateLiveMessage: updateLiveMessage, updateLiveMessage: updateLiveMessage,
cancelLiveMessage: { cancelLiveMessage: {
composeState.liveMessage = nil composeState.liveMessage = nil
@@ -384,10 +384,10 @@ struct ComposeView: View {
} }
} }
.sheet(isPresented: $showMediaPicker) { .sheet(isPresented: $showMediaPicker) {
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
await MainActor.run { showMediaPicker = false
showMediaPicker = false if itemsSelected {
if itemsSelected { DispatchQueue.main.async {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: [])) composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
} }
} }
@@ -488,30 +488,6 @@ struct ComposeView: View {
} }
} }
private func addMediaContent(_ content: UploadContent) async {
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content))
newMedia = media
} else {
newMedia = [(img, content)]
}
await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
}
}
}
// When error occurs while converting video, remove media preview
private func finishedPreprocessingMediaContent() {
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .noPreview)
}
}
}
private var maxFileSize: Int64 { private var maxFileSize: Int64 {
getMaxFileSize(.xftp) getMaxFileSize(.xftp)
} }
@@ -689,7 +665,7 @@ struct ComposeView: View {
let file = voiceCryptoFile(recordingFileName) let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
case let .filePreview(_, file): case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) { if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
} }
@@ -792,17 +768,15 @@ struct ComposeView: View {
} }
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItem = chat.chatInfo.chatType == .local if let chatItem = await apiSendMessage(
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc) type: chat.chatInfo.chatType,
: await apiSendMessage( id: chat.chatInfo.apiId,
type: chat.chatInfo.chatType, file: file,
id: chat.chatInfo.apiId, quotedItemId: quoted,
file: file, msg: mc,
quotedItemId: quoted, live: live,
msg: mc, ttl: ttl
live: live, ) {
ttl: ttl
) {
await MainActor.run { await MainActor.run {
chatModel.removeLiveDummy(animated: false) chatModel.removeLiveDummy(animated: false)
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
@@ -978,9 +952,6 @@ struct ComposeView: View {
} }
private func cancelLinkPreview() { private func cancelLinkPreview() {
if let pendingLink = pendingLinkUrl?.absoluteString {
cancelledLinks.insert(pendingLink)
}
if let uri = composeState.linkPreview?.uri.absoluteString { if let uri = composeState.linkPreview?.uri.absoluteString {
cancelledLinks.insert(uri) cancelledLinks.insert(uri)
} }
@@ -51,8 +51,7 @@ struct ContextItemView: View {
MsgContentView( MsgContentView(
chat: chat, chat: chat,
text: contextItem.text, text: contextItem.text,
formattedText: contextItem.formattedText, formattedText: contextItem.formattedText
showSecrets: false
) )
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines) .lineLimit(lines)
@@ -116,6 +116,7 @@ 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))
.frame(height: 36, alignment: .topLeading)
} }
private func savePreferences() { private func savePreferences() {
@@ -157,7 +157,7 @@ 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.allCases) { role in ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole && role != .author { if role <= groupInfo.membership.memberRole {
Text(role.text) Text(role.text)
} }
} }
@@ -16,6 +16,7 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@@ -36,8 +37,6 @@ struct GroupChatInfoView: View {
case largeGroupReceiptsDisabled case largeGroupReceiptsDisabled
case blockMemberAlert(mem: GroupMember) case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember)
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey) case error(title: LocalizedStringKey, error: LocalizedStringKey)
@@ -50,8 +49,6 @@ struct GroupChatInfoView: View {
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
@@ -147,8 +144,6 @@ struct GroupChatInfoView: View {
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return Alert(title: Text(title), message: Text(error)) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
@@ -232,44 +227,20 @@ struct GroupChatInfoView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
memberInfo(member)
}
if user {
v
} else if groupInfo.membership.memberRole >= .admin {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
if canBlockForAll && canRemove {
removeSwipe(member, blockForAllSwipe(member, v))
} else if canBlockForAll {
blockForAllSwipe(member, v)
} else if canRemove {
removeSwipe(member, v)
} else {
v
}
} else {
if !member.blockedByAdmin {
blockSwipe(member, v)
} else {
v
}
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
.foregroundColor(.secondary)
} else {
let role = member.memberRole let role = member.memberRole
if [.owner, .admin, .observer].contains(role) { if role == .owner || role == .admin {
Text(member.memberRole.text) Text(member.memberRole.text)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
if user {
v
} else if member.canBeRemoved(groupInfo: groupInfo) {
removeSwipe(member, blockSwipe(member, v))
} else {
blockSwipe(member, v)
}
} }
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View { private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
@@ -290,24 +261,6 @@ struct GroupChatInfoView: View {
} }
} }
private func blockForAllSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .leading) {
if member.blockedByAdmin {
Button {
alert = .unblockForAllAlert(mem: member)
} label: {
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
}
} else {
Button {
alert = .blockForAllAlert(mem: member)
} label: {
Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
}
}
}
}
private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View { private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .trailing) { v.swipeActions(edge: .trailing) {
Button(role: .destructive) { Button(role: .destructive) {
@@ -360,11 +313,7 @@ struct GroupChatInfoView: View {
private func addOrEditWelcomeMessage() -> some View { private func addOrEditWelcomeMessage() -> some View {
NavigationLink { NavigationLink {
GroupWelcomeView( GroupWelcomeView(groupId: groupInfo.groupId, groupInfo: $groupInfo)
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile,
welcomeText: groupInfo.groupProfile.description ?? ""
)
.navigationTitle("Welcome message") .navigationTitle("Welcome message")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
@@ -18,7 +18,6 @@ struct GroupLinkView: View {
var linkCreatedCb: (() -> Void)? = nil var linkCreatedCb: (() -> Void)? = nil
@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
private enum GroupLinkAlert: Identifiable { private enum GroupLinkAlert: Identifiable {
case deleteLink case deleteLink
@@ -71,7 +70,6 @@ struct GroupLinkView: View {
} }
.frame(height: 36) .frame(height: 36)
SimpleXLinkQRCode(uri: groupLink) SimpleXLinkQRCode(uri: groupLink)
.id("simplex-qrcode-view-for-\(groupLink)")
Button { Button {
showShareSheet(items: [simplexChatLink(groupLink)]) showShareSheet(items: [simplexChatLink(groupLink)])
} label: { } label: {
@@ -127,10 +125,9 @@ struct GroupLinkView: View {
} }
} }
.onAppear { .onAppear {
if groupLink == nil && !creatingLink && shouldCreate { if groupLink == nil && !creatingLink {
createGroupLink() createGroupLink()
} }
shouldCreate = false
} }
} }
} }
@@ -27,8 +27,6 @@ struct GroupMemberInfoView: View {
enum GroupMemberInfoViewAlert: Identifiable { enum GroupMemberInfoViewAlert: Identifiable {
case blockMemberAlert(mem: GroupMember) case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember)
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert case switchAddressAlert
@@ -41,8 +39,6 @@ struct GroupMemberInfoView: View {
switch self { switch self {
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)" case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
@@ -168,10 +164,15 @@ struct GroupMemberInfoView: View {
} }
} }
if groupInfo.membership.memberRole >= .admin { Section {
adminDestructiveSection(member) if member.memberSettings.showMessages {
} else { blockMemberButton(member)
nonAdminBlockSection(member) } else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
} }
if developerTools { if developerTools {
@@ -187,19 +188,17 @@ struct GroupMemberInfoView: View {
// this condition prevents re-setting picker // this condition prevents re-setting picker
if !justOpened { return } if !justOpened { return }
} }
justOpened = false newRole = member.memberRole
DispatchQueue.main.async { do {
newRole = member.memberRole let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
do { let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) _ = chatModel.upsertGroupMember(groupInfo, mem)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) connectionStats = stats
_ = chatModel.upsertGroupMember(groupInfo, mem) connectionCode = code
connectionStats = stats } catch let error {
connectionCode = code logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
} }
justOpened = false
} }
.onChange(of: newRole) { newRole in .onChange(of: newRole) { newRole in
if newRole != member.memberRole { if newRole != member.memberRole {
@@ -215,8 +214,6 @@ struct GroupMemberInfoView: View {
switch(alertItem) { switch(alertItem) {
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
@@ -386,55 +383,6 @@ struct GroupMemberInfoView: View {
} }
} }
@ViewBuilder private func adminDestructiveSection(_ mem: GroupMember) -> some View {
let canBlockForAll = mem.canBlockForAll(groupInfo: groupInfo)
let canRemove = mem.canBeRemoved(groupInfo: groupInfo)
if canBlockForAll || canRemove {
Section {
if canBlockForAll {
if mem.blockedByAdmin {
unblockForAllButton(mem)
} else {
blockForAllButton(mem)
}
}
if canRemove {
removeMemberButton(mem)
}
}
}
}
private func nonAdminBlockSection(_ mem: GroupMember) -> some View {
Section {
if mem.blockedByAdmin {
Label("Blocked by admin", systemImage: "hand.raised")
.foregroundColor(.secondary)
} else if mem.memberSettings.showMessages {
blockMemberButton(mem)
} else {
unblockMemberButton(mem)
}
}
}
private func blockForAllButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .blockForAllAlert(mem: mem)
} label: {
Label("Block for all", systemImage: "hand.raised")
.foregroundColor(.red)
}
}
private func unblockForAllButton(_ mem: GroupMember) -> some View {
Button {
alert = .unblockForAllAlert(mem: mem)
} label: {
Label("Unblock for all", systemImage: "hand.raised.slash")
}
}
private func blockMemberButton(_ mem: GroupMember) -> some View { private func blockMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) { Button(role: .destructive) {
alert = .blockMemberAlert(mem: mem) alert = .blockMemberAlert(mem: mem)
@@ -610,41 +558,6 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
} }
} }
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member for all?"),
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
primaryButton: .destructive(Text("Block for all")) {
blockMemberForAll(gInfo, mem, true)
},
secondaryButton: .cancel()
)
}
func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Unblock member for all?"),
message: Text("Messages from \(mem.chatViewName) will be shown!"),
primaryButton: .default(Text("Unblock for all")) {
blockMemberForAll(gInfo, mem, false)
},
secondaryButton: .cancel()
)
}
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
Task {
do {
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
}
} catch let error {
logger.error("apiBlockMemberForAll error: \(responseError(error))")
}
}
}
struct GroupMemberInfoView_Previews: PreviewProvider { struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupMemberInfoView( GroupMemberInfoView(
@@ -28,7 +28,6 @@ struct GroupPreferencesView: View {
featureSection(.reactions, $preferences.reactions.enable) featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable) featureSection(.voice, $preferences.voice.enable)
featureSection(.files, $preferences.files.enable) featureSection(.files, $preferences.files.enable)
featureSection(.history, $preferences.history.enable)
if groupInfo.canEdit { if groupInfo.canEdit {
Section { Section {
@@ -97,6 +96,7 @@ struct GroupPreferencesView: View {
} }
} footer: { } footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit)) Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.frame(height: 36, alignment: .topLeading)
} }
} }
@@ -103,10 +103,8 @@ struct GroupProfileView: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { _ in LibraryImagePicker(image: $chosenImage) {
await MainActor.run { didSelectItem in showImagePicker = false
showImagePicker = false
}
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
@@ -11,32 +11,29 @@ import SimpleXChat
struct GroupWelcomeView: View { struct GroupWelcomeView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var m: ChatModel
var groupId: Int64
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile @State private var welcomeText: String = ""
@State var welcomeText: String
@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
let maxByteCount = 1200
var body: some View { var body: some View {
VStack { VStack {
if groupInfo.canEdit { if groupInfo.canEdit {
editorView() editorView()
.modifier(BackButton { .modifier(BackButton {
if welcomeTextUnchanged() { if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
dismiss() dismiss()
} else { } else {
showSaveDialog = true showSaveDialog = true
} }
}) })
.confirmationDialog( .confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
welcomeTextFitsLimit() ? "Save welcome message?" : "Welcome message is too long", Button("Save and update group profile") {
isPresented: $showSaveDialog save()
) { dismiss()
if welcomeTextFitsLimit() {
Button("Save and update group profile") { save() }
} }
Button("Exit without saving") { dismiss() } Button("Exit without saving") { dismiss() }
} }
@@ -50,15 +47,15 @@ struct GroupWelcomeView: View {
} }
} }
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { welcomeText = groupInfo.groupProfile.description ?? ""
keyboardVisible = true keyboardVisible = true
}
} }
} }
private func textPreview() -> some View { private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false) messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil)
.frame(minHeight: 130, alignment: .topLeading) .allowsHitTesting(false)
.frame(minHeight: 140, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -78,7 +75,7 @@ struct GroupWelcomeView: View {
} }
.padding(.horizontal, -5) .padding(.horizontal, -5)
.padding(.top, -8) .padding(.top, -8)
.frame(height: 130, alignment: .topLeading) .frame(height: 140, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} else { } else {
@@ -97,9 +94,6 @@ struct GroupWelcomeView: View {
} }
.disabled(welcomeText.isEmpty) .disabled(welcomeText.isEmpty)
copyButton() copyButton()
} footer: {
Text(!welcomeTextFitsLimit() ? "Message too large" : "")
.foregroundColor(.red)
} }
Section { Section {
@@ -120,15 +114,7 @@ struct GroupWelcomeView: View {
Button("Save and update group profile") { Button("Save and update group profile") {
save() save()
} }
.disabled(welcomeTextUnchanged() || !welcomeTextFitsLimit()) .disabled(welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil))
}
private func welcomeTextUnchanged() -> Bool {
welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil)
}
private func welcomeTextFitsLimit() -> Bool {
chatJsonLength(welcomeText) <= maxByteCount
} }
private func save() { private func save() {
@@ -138,13 +124,11 @@ struct GroupWelcomeView: View {
if welcome?.count == 0 { if welcome?.count == 0 {
welcome = nil welcome = nil
} }
groupProfile.description = welcome var groupProfileUpdated = groupInfo.groupProfile
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) groupProfileUpdated.description = welcome
await MainActor.run { groupInfo = try await apiUpdateGroup(groupId, groupProfileUpdated)
groupInfo = gInfo m.updateGroup(groupInfo)
ChatModel.shared.updateGroup(gInfo) welcomeText = welcome ?? ""
dismiss()
}
} catch let error { } catch let error {
logger.error("apiUpdateGroup error: \(responseError(error))") logger.error("apiUpdateGroup error: \(responseError(error))")
} }
@@ -154,6 +138,6 @@ struct GroupWelcomeView: View {
struct GroupWelcomeView_Previews: PreviewProvider { struct GroupWelcomeView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData) GroupWelcomeView(groupId: 1, groupInfo: Binding.constant(GroupInfo.sampleData))
} }
} }
@@ -17,7 +17,7 @@ struct ScanCodeView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
Text("Scan security code from your contact's app.") Text("Scan security code from your contact's app.")
@@ -11,7 +11,7 @@ import SwiftUI
struct ChatHelp: View { struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool @Binding var showSettings: Bool
@State private var newChatMenuOption: NewChatMenuOption? = nil @State private var showAddChat = false
var body: some View { var body: some View {
ScrollView { chatHelp() } ScrollView { chatHelp() }
@@ -39,12 +39,13 @@ struct ChatHelp: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Tap button ") Text("Tap button ")
NewChatMenuButton(newChatMenuOption: $newChatMenuOption) NewChatButton(showAddChat: $showAddChat)
Text("above, then choose:") Text("above, then choose:")
} }
Text("**Add contact**: to create a new invitation link, or connect via a link you received.") Text("**Create link / QR code** for your contact to use.")
Text("**Create group**: to create a new group.") Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.")
Text("**Scan QR code**: to connect to your contact in person or via video call.")
} }
.padding(.top, 24) .padding(.top, 24)
@@ -44,8 +44,6 @@ struct ChatListNavLink: View {
contactNavLink(contact) contactNavLink(contact)
case let .group(groupInfo): case let .group(groupInfo):
groupNavLink(groupInfo) groupNavLink(groupInfo)
case let .local(noteFolder):
noteFolderNavLink(noteFolder)
case let .contactRequest(cReq): case let .contactRequest(cReq):
contactRequestNavLink(cReq) contactRequestNavLink(cReq)
case let .contactConnection(cConn): case let .contactConnection(cConn):
@@ -197,24 +195,6 @@ struct ChatListNavLink: View {
} }
} }
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
clearNoteFolderButton()
}
}
}
private func joinGroupButton() -> some View { private func joinGroupButton() -> some View {
Button { Button {
inProgress = true inProgress = true
@@ -273,15 +253,6 @@ struct ChatListNavLink: View {
.tint(Color.orange) .tint(Color.orange)
} }
private func clearNoteFolderButton() -> some View {
Button {
AlertManager.shared.showAlert(clearNoteFolderAlert())
} label: {
Label("Clear", systemImage: "gobackward")
}
.tint(Color.orange)
}
private func leaveGroupChatButton(_ groupInfo: GroupInfo) -> some View { private func leaveGroupChatButton(_ groupInfo: GroupInfo) -> some View {
Button { Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
@@ -386,17 +357,6 @@ struct ChatListNavLink: View {
) )
} }
private func clearNoteFolderAlert() -> Alert {
Alert(
title: Text("Clear private notes?"),
message: Text("All messages will be deleted - this cannot be undone!"),
primaryButton: .destructive(Text("Clear")) {
Task { await clearChat(chat) }
},
secondaryButton: .cancel()
)
}
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert( Alert(
title: Text("Leave group?"), title: Text("Leave group?"),
+22 -161
View File
@@ -12,12 +12,8 @@ import SimpleXChat
struct ChatListView: View { struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool @Binding var showSettings: Bool
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = "" @State private var searchText = ""
@State private var searchShowingSimplexLink = false @State private var showAddChat = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false @State private var userPickerVisible = false
@State private var showConnectDesktop = false @State private var showConnectDesktop = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@@ -66,7 +62,11 @@ struct ChatListView: View {
private var chatListView: some View { private var chatListView: some View {
VStack { VStack {
chatList if chatModel.chats.count > 0 {
chatList.searchable(text: $searchText)
} else {
chatList
}
} }
.onDisappear() { withAnimation { userPickerVisible = false } } .onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable { .refreshable {
@@ -85,9 +85,9 @@ struct ChatListView: View {
secondaryButton: .cancel() secondaryButton: .cancel()
)) ))
} }
.offset(x: -8)
.listStyle(.plain) .listStyle(.plain)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
let user = chatModel.currentUser ?? User.sampleData let user = chatModel.currentUser ?? User.sampleData
@@ -124,7 +124,7 @@ struct ChatListView: View {
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning { switch chatModel.chatRunning {
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption) case .some(true): NewChatButton(showAddChat: $showAddChat)
case .some(false): chatStoppedIcon() case .some(false): chatStoppedIcon()
case .none: EmptyView() case .none: EmptyView()
} }
@@ -144,25 +144,11 @@ struct ChatListView: View {
@ViewBuilder private var chatList: some View { @ViewBuilder private var chatList: some View {
let cs = filteredChats() let cs = filteredChats()
ZStack { ZStack {
VStack { List {
List { ForEach(cs, id: \.viewId) { chat in
if !chatModel.chats.isEmpty { ChatListNavLink(chat: chat)
ChatListSearchBar( .padding(.trailing, -16)
searchMode: $searchMode, .disabled(chatModel.chatRunning != true)
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
.offset(x: -8)
} }
} }
.onChange(of: chatModel.chatId) { _ in .onChange(of: chatModel.chatId) { _ in
@@ -196,7 +182,7 @@ struct ChatListView: View {
.padding(.trailing, 12) .padding(.trailing, 12)
connectButton("Tap to start a new chat") { connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact showAddChat = true
} }
Spacer() Spacer()
@@ -228,27 +214,22 @@ struct ChatListView: View {
} }
private func filteredChats() -> [Chat] { private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
return chatModel.chats.filter { $0.id == linkChatId } return s == "" && !showUnreadAndFavorites
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
? chatModel.chats ? chatModel.chats
: chatModel.chats.filter { chat in : chatModel.chats.filter { chat in
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
switch cInfo { switch cInfo {
case let .direct(contact): case let .direct(contact):
return s == "" return s == ""
? filtered(chat) ? filtered(chat)
: (viewNameContains(cInfo, s) || : (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) || contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s)) contact.fullName.localizedLowercase.contains(s))
case let .group(gInfo): case let .group(gInfo):
return s == "" return s == ""
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited) ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
: viewNameContains(cInfo, s) : viewNameContains(cInfo, s)
case .local:
return s == "" || viewNameContains(cInfo, s)
case .contactRequest: case .contactRequest:
return s == "" || viewNameContains(cInfo, s) return s == "" || viewNameContains(cInfo, s)
case let .contactConnection(conn): case let .contactConnection(conn):
@@ -257,11 +238,6 @@ struct ChatListView: View {
return false return false
} }
} }
}
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func filtered(_ chat: Chat) -> Bool { func filtered(_ chat: Chat) -> Bool {
(chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat (chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
@@ -273,121 +249,6 @@ struct ChatListView: View {
} }
} }
struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
@State private var showScanCodeSheet = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search or paste SimpleX link", text: $searchText)
.foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
searchText = ""
}
} else if !searchFocussed {
HStack(spacing: 24) {
if m.pasteboardHasStrings {
Image(systemName: "doc")
.onTapGesture {
if let str = UIPasteboard.general.string {
searchText = str
}
}
}
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.onTapGesture {
showScanCodeSheet = true
}
}
.padding(.trailing, 2)
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(.accentColor)
.onTapGesture {
searchText = ""
searchFocussed = false
}
}
}
Divider()
}
.sheet(isPresented: $showScanCodeSheet) {
NewChatView(selection: .connect, showQRCodeScanner: true)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
.onChange(of: searchText) { t in
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
if case let .simplexLink(linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connect(link.text)
} else {
if t != "" { // if some other text is pasted, enter search mode
searchFocussed = true
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
.alert(item: $alert) { a in
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
}
.actionSheet(item: $sheet) { s in
planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = $0 },
showActionSheet: { sheet = $0 },
dismiss: false,
incognito: nil,
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
)
}
}
func chatStoppedIcon() -> some View { func chatStoppedIcon() -> some View {
Button { Button {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -13,7 +13,6 @@ struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool @Binding var progressByTimeout: Bool
@State var deleting: Bool = false
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0) var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@@ -34,7 +33,7 @@ struct ChatPreviewView: View {
HStack(alignment: .top) { HStack(alignment: .top) {
chatPreviewTitle() chatPreviewTitle()
Spacer() Spacer()
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs)) (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt))
.font(.subheadline) .font(.subheadline)
.frame(minWidth: 60, alignment: .trailing) .frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -56,9 +55,6 @@ struct ChatPreviewView: View {
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
.padding(.bottom, -8) .padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
}
} }
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
@@ -91,13 +87,13 @@ struct ChatPreviewView: View {
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold) let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil) previewTitle(contact.verified == true ? verifiedIcon + t : t)
case let .group(groupInfo): case let .group(groupInfo):
let v = previewTitle(t) let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: v.foregroundColor(deleting ? .secondary : chat.chatInfo.incognito ? .indigo : .accentColor) case .memInvited: v.foregroundColor(chat.chatInfo.incognito ? .indigo : .accentColor)
case .memAccepted: v.foregroundColor(.secondary) case .memAccepted: v.foregroundColor(.secondary)
default: if deleting { v.foregroundColor(.secondary) } else { v } default: v
} }
default: previewTitle(t) default: previewTitle(t)
} }
@@ -134,9 +130,9 @@ struct ChatPreviewView: View {
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 4) .padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18) .frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? Color.accentColor : Color.secondary) .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary)
.cornerRadius(10) .cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local { } else if !chat.chatInfo.ntfsEnabled {
Image(systemName: "speaker.slash.fill") Image(systemName: "speaker.slash.fill")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false { } else if chat.chatInfo.chatSettings?.favorite ?? false {
@@ -154,7 +150,7 @@ struct ChatPreviewView: View {
let msg = draft.message let msg = draft.message
return image("rectangle.and.pencil.and.ellipsis", color: .accentColor) return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
+ attachment() + attachment()
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false) + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true)
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) + Text(" ") Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
@@ -171,20 +167,9 @@ struct ChatPreviewView: View {
} }
func chatItemPreview(_ cItem: ChatItem) -> Text { func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemText = cItem.meta.itemDeleted == nil ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false) return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String {
switch cItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
}
}
func attachment() -> String? { func attachment() -> String? {
switch cItem.content.msgContent { switch cItem.content.msgContent {
@@ -164,28 +164,6 @@ struct ContactConnectionInfo: View {
} }
} }
private func shareLinkButton(_ connReqInvitation: String) -> some View {
Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: {
settingsRow("square.and.arrow.up") {
Text("Share 1-time link")
}
}
}
private func oneTimeLinkLearnMoreButton() -> some View {
NavigationLink {
AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("info.circle") {
Text("Learn more")
}
}
}
struct ContactConnectionInfo_Previews: PreviewProvider { struct ContactConnectionInfo_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ContactConnectionInfo(contactConnection: PendingContactConnection.getSampleData()) ContactConnectionInfo(contactConnection: PendingContactConnection.getSampleData())
@@ -149,7 +149,7 @@ struct DatabaseErrorView: View {
private func runChatSync(confirmMigrations: MigrationConfirmation? = nil) { private func runChatSync(confirmMigrations: MigrationConfirmation? = nil) {
do { do {
resetChatCtrl() resetChatCtrl()
try initializeChat(start: m.v3DBMigration.startChat, confirmStart: m.v3DBMigration.startChat && AppChatState.shared.value == .stopped, dbKey: useKeychain ? nil : dbKey, confirmMigrations: confirmMigrations) try initializeChat(start: m.v3DBMigration.startChat, dbKey: useKeychain ? nil : dbKey, confirmMigrations: confirmMigrations)
if let s = m.chatDbStatus { if let s = m.chatDbStatus {
status = s status = s
let am = AlertManager.shared let am = AlertManager.shared
@@ -415,7 +415,7 @@ struct DatabaseView: View {
do { do {
try initializeChat(start: true) try initializeChat(start: true)
m.chatDbChanged = false m.chatDbChanged = false
AppChatState.shared.set(.active) appStateGroupDefault.set(.active)
} catch let error { } catch let error {
fatalError("Error starting chat \(responseError(error))") fatalError("Error starting chat \(responseError(error))")
} }
@@ -427,7 +427,7 @@ struct DatabaseView: View {
m.chatRunning = true m.chatRunning = true
ChatReceiver.shared.start() ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now) chatLastStartGroupDefault.set(Date.now)
AppChatState.shared.set(.active) appStateGroupDefault.set(.active)
} catch let error { } catch let error {
runChat = false runChat = false
alert = .error(title: "Error starting chat", error: responseError(error)) alert = .error(title: "Error starting chat", error: responseError(error))
@@ -477,14 +477,13 @@ func stopChatAsync() async throws {
try await apiStopChat() try await apiStopChat()
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
await MainActor.run { ChatModel.shared.chatRunning = false } await MainActor.run { ChatModel.shared.chatRunning = false }
AppChatState.shared.set(.stopped) appStateGroupDefault.set(.stopped)
} }
func deleteChatAsync() async throws { func deleteChatAsync() async throws {
try await apiDeleteStorage() try await apiDeleteStorage()
_ = kcDatabasePassword.remove() _ = kcDatabasePassword.remove()
storeDBPassphraseGroupDefault.set(true) storeDBPassphraseGroupDefault.set(true)
deleteAppDatabaseAndFiles()
} }
struct DatabaseView_Previews: PreviewProvider { struct DatabaseView_Previews: PreviewProvider {
@@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatInfoImage: View { struct ChatInfoImage: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var color = Color(uiColor: .tertiarySystemGroupedBackground) var color = Color(uiColor: .tertiarySystemGroupedBackground)
@@ -19,16 +18,13 @@ struct ChatInfoImage: View {
switch chat.chatInfo { switch chat.chatInfo {
case .direct: iconName = "person.crop.circle.fill" case .direct: iconName = "person.crop.circle.fill"
case .group: iconName = "person.2.circle.fill" case .group: iconName = "person.2.circle.fill"
case .local: iconName = "folder.circle.fill"
case .contactRequest: iconName = "person.crop.circle.fill" case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill" default: iconName = "circle.fill"
} }
let notesColor = colorScheme == .light ? notesChatColorLight : notesChatColorDark
let iconColor = if case .local = chat.chatInfo { notesColor } else { color }
return ProfileImage( return ProfileImage(
imageStr: chat.chatInfo.image, imageStr: chat.chatInfo.image,
iconName: iconName, iconName: iconName,
color: iconColor color: color
) )
} }
} }
+79 -97
View File
@@ -13,130 +13,112 @@ import SimpleXChat
struct LibraryImagePicker: View { struct LibraryImagePicker: View {
@Binding var image: UIImage? @Binding var image: UIImage?
var didFinishPicking: (_ didSelectImage: Bool) async -> Void var didFinishPicking: (_ didSelectItems: Bool) -> Void
@State var mediaAdded = false @State var images: [UploadContent] = []
var body: some View { var body: some View {
LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking) LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
} .onChange(of: images) { _ in
if let img = images.first {
private func addMedia(_ content: UploadContent) async { image = img.uiImage
if mediaAdded { return } }
await MainActor.run { }
mediaAdded = true
image = content.uiImage
}
} }
} }
struct LibraryMediaListPicker: UIViewControllerRepresentable { struct LibraryMediaListPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController typealias UIViewControllerType = PHPickerViewController
var addMedia: (_ content: UploadContent) async -> Void @Binding var media: [UploadContent]
var selectionLimit: Int var selectionLimit: Int
var finishedPreprocessing: () -> Void = {} var didFinishPicking: (_ didSelectItems: Bool) -> Void
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
class Coordinator: PHPickerViewControllerDelegate { class Coordinator: PHPickerViewControllerDelegate {
let parent: LibraryMediaListPicker let parent: LibraryMediaListPicker
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker") let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
var media: [UploadContent] = []
var mediaCount: Int = 0
init(_ parent: LibraryMediaListPicker) { init(_ parent: LibraryMediaListPicker) {
self.parent = parent self.parent = parent
} }
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
Task { parent.didFinishPicking(!results.isEmpty)
await parent.didFinishPicking(!results.isEmpty) guard !results.isEmpty else {
if results.isEmpty { return } return
for r in results {
await loadItem(r.itemProvider)
}
parent.finishedPreprocessing()
} }
}
private func loadItem(_ p: NSItemProvider) async { parent.media = []
logger.debug("LibraryMediaListPicker result") media = []
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { mediaCount = results.count
if let video = await loadVideo(p) { for result in results {
await self.parent.addMedia(video) logger.log("LibraryMediaListPicker result")
logger.debug("LibraryMediaListPicker: added video") let p = result.itemProvider
} if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let img = await loadImageData(p) { if let url = url {
await self.parent.addMedia(img) let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
logger.debug("LibraryMediaListPicker: added image") if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
} ChatModel.shared.filesToDelete.insert(tempUrl)
} else if p.canLoadObject(ofClass: UIImage.self) { self.loadVideo(url: tempUrl, error: error)
if let img = await loadImage(p) {
await self.parent.addMedia(.simpleImage(image: img))
logger.debug("LibraryMediaListPicker: added image")
}
}
}
private func loadImageData(_ p: NSItemProvider) async -> UploadContent? {
await withCheckedContinuation { cont in
loadFileURL(p, type: UTType.data) { url in
if let url = url {
let img = UploadContent.loadFromURL(url: url)
cont.resume(returning: img)
} else {
cont.resume(returning: nil)
}
}
}
}
private func loadImage(_ p: NSItemProvider) async -> UIImage? {
await withCheckedContinuation { cont in
p.loadObject(ofClass: UIImage.self) { obj, err in
if let err = err {
logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)")
cont.resume(returning: nil)
} else {
cont.resume(returning: obj as? UIImage)
}
}
}
}
private func loadVideo(_ p: NSItemProvider) async -> UploadContent? {
await withCheckedContinuation { cont in
loadFileURL(p, type: UTType.movie) { url in
if let url = url {
let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "rawvideo", url.pathExtension, fullPath: true))
let convertedVideoUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "video", "mp4", fullPath: true))
do {
// logger.debug("LibraryMediaListPicker copyItem \(url) to \(tempUrl)")
try FileManager.default.copyItem(at: url, to: tempUrl)
} catch let err {
logger.error("LibraryMediaListPicker copyItem error: \(err.localizedDescription)")
return cont.resume(returning: nil)
}
Task {
let success = await makeVideoQualityLower(tempUrl, outputUrl: convertedVideoUrl)
try? FileManager.default.removeItem(at: tempUrl)
if success {
_ = ChatModel.shared.filesToDelete.insert(convertedVideoUrl)
let video = UploadContent.loadVideoFromURL(url: convertedVideoUrl)
return cont.resume(returning: video)
} }
try? FileManager.default.removeItem(at: convertedVideoUrl)
cont.resume(returning: nil)
} }
} }
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
self.loadImage(object: url, error: error)
}
} else if p.canLoadObject(ofClass: UIImage.self) {
p.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.async {
self.loadImage(object: image, error: error)
}
}
} else {
dispatchQueue.sync { self.mediaCount -= 1}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.dispatchQueue.sync {
if self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
self.parent.media = self.media
}
} }
} }
} }
private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) { func loadImage(object: Any?, error: Error? = nil) {
p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in if let error = error {
if let err = err { logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)") } else if let image = object as? UIImage {
completion(nil) media.append(.simpleImage(image: image))
} else { logger.log("LibraryMediaListPicker: added image")
completion(url) } else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
media.append(image)
}
dispatchQueue.sync {
self.mediaCount -= 1
if self.mediaCount == 0 && self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added all media")
self.parent.media = self.media
self.media = []
}
}
}
func loadVideo(url: URL?, error: Error? = nil) {
if let error = error {
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
media.append(video)
}
dispatchQueue.sync {
self.mediaCount -= 1
if self.mediaCount == 0 && self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added all media")
self.parent.media = self.media
self.media = []
} }
} }
} }
@@ -1,26 +0,0 @@
//
// VideoUtils.swift
// SimpleX (iOS)
//
// Created by Avently on 25.12.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import AVFoundation
import Foundation
import SimpleXChat
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
@@ -13,28 +13,19 @@ struct LocalAuthView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
var authRequest: LocalAuthRequest var authRequest: LocalAuthRequest
@State private var password = "" @State private var password = ""
@State private var allowToReact = true
var body: some View { var body: some View {
PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit", PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit") {
buttonsEnabled: $allowToReact) {
if let sdPassword = kcSelfDestructPassword.get(), authRequest.selfDestruct && password == sdPassword { if let sdPassword = kcSelfDestructPassword.get(), authRequest.selfDestruct && password == sdPassword {
allowToReact = false
deleteStorageAndRestart(sdPassword) { r in deleteStorageAndRestart(sdPassword) { r in
m.laRequest = nil m.laRequest = nil
authRequest.completed(r) authRequest.completed(r)
} }
return return
} }
let r: LAResult let r: LAResult = password == authRequest.password
if password == authRequest.password { ? .success
if authRequest.selfDestruct && kcSelfDestructPassword.get() != nil && !m.chatInitialized { : .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
initChatAndMigrate()
}
r = .success
} else {
r = .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
}
m.laRequest = nil m.laRequest = nil
authRequest.completed(r) authRequest.completed(r)
} cancel: { } cancel: {
@@ -46,27 +37,8 @@ struct LocalAuthView: View {
private func deleteStorageAndRestart(_ password: String, completed: @escaping (LAResult) -> Void) { private func deleteStorageAndRestart(_ password: String, completed: @escaping (LAResult) -> Void) {
Task { Task {
do { do {
/** Waiting until [initializeChat] finishes */ try await stopChatAsync()
while (m.ctrlInitInProgress) { try await deleteChatAsync()
try await Task.sleep(nanoseconds: 50_000000)
}
if m.chatRunning == true {
try await stopChatAsync()
}
if m.chatInitialized {
/**
* The following sequence can bring a user here:
* the user opened the app, entered app passcode, went to background, returned back, entered self-destruct code.
* In this case database should be closed to prevent possible situation when OS can deny database removal command
* */
chatCloseStore()
}
deleteAppDatabaseAndFiles()
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
m.reversedChatItems = []
m.chats = []
m.users = []
_ = kcAppPassword.set(password) _ = kcAppPassword.set(password)
_ = kcSelfDestructPassword.remove() _ = kcSelfDestructPassword.remove()
await NtfManager.shared.removeAllNotifications() await NtfManager.shared.removeAllNotifications()
@@ -80,8 +52,8 @@ struct LocalAuthView: View {
resetChatCtrl() resetChatCtrl()
try initializeChat(start: true) try initializeChat(start: true)
m.chatDbChanged = false m.chatDbChanged = false
AppChatState.shared.set(.active) appStateGroupDefault.set(.active)
if m.currentUser != nil || !m.chatInitialized { return } if m.currentUser != nil { return }
var profile: Profile? = nil var profile: Profile? = nil
if let displayName = displayName, displayName != "" { if let displayName = displayName, displayName != "" {
profile = Profile(displayName: displayName, fullName: "") profile = Profile(displayName: displayName, fullName: "")
@@ -14,8 +14,6 @@ struct PasscodeView: View {
var reason: String? = nil var reason: String? = nil
var submitLabel: LocalizedStringKey var submitLabel: LocalizedStringKey
var submitEnabled: ((String) -> Bool)? var submitEnabled: ((String) -> Bool)?
@Binding var buttonsEnabled: Bool
var submit: () -> Void var submit: () -> Void
var cancel: () -> Void var cancel: () -> Void
@@ -72,11 +70,11 @@ struct PasscodeView: View {
@ViewBuilder private func buttonsView() -> some View { @ViewBuilder private func buttonsView() -> some View {
Button(action: cancel) { Button(action: cancel) {
Label("Cancel", systemImage: "multiply") Label("Cancel", systemImage: "multiply")
}.disabled(!buttonsEnabled) }
Button(action: submit) { Button(action: submit) {
Label(submitLabel, systemImage: "checkmark") Label(submitLabel, systemImage: "checkmark")
} }
.disabled(submitEnabled?(passcode) == false || passcode.count < 4 || !buttonsEnabled) .disabled(submitEnabled?(passcode) == false || passcode.count < 4)
} }
} }
@@ -87,7 +85,6 @@ struct PasscodeViewView_Previews: PreviewProvider {
title: "Enter Passcode", title: "Enter Passcode",
reason: "Unlock app", reason: "Unlock app",
submitLabel: "Submit", submitLabel: "Submit",
buttonsEnabled: Binding.constant(true),
submit: {}, submit: {},
cancel: {} cancel: {}
) )
@@ -11,7 +11,6 @@ import SimpleXChat
struct SetAppPasscodeView: View { struct SetAppPasscodeView: View {
var passcodeKeychain: KeyChainItem = kcAppPassword var passcodeKeychain: KeyChainItem = kcAppPassword
var prohibitedPasscodeKeychain: KeyChainItem = kcSelfDestructPassword
var title: LocalizedStringKey = "New Passcode" var title: LocalizedStringKey = "New Passcode"
var reason: String? var reason: String?
var submit: () -> Void var submit: () -> Void
@@ -42,10 +41,7 @@ struct SetAppPasscodeView: View {
} }
} }
} else { } else {
setPasswordView(title: title, setPasswordView(title: title, submitLabel: "Save") {
submitLabel: "Save",
// Do not allow to set app passcode == selfDestruct passcode
submitEnabled: { pwd in pwd != prohibitedPasscodeKeychain.get() }) {
enteredPassword = passcode enteredPassword = passcode
passcode = "" passcode = ""
confirming = true confirming = true
@@ -58,7 +54,7 @@ struct SetAppPasscodeView: View {
} }
private func setPasswordView(title: LocalizedStringKey, submitLabel: LocalizedStringKey, submitEnabled: (((String) -> Bool))? = nil, submit: @escaping () -> Void) -> some View { private func setPasswordView(title: LocalizedStringKey, submitLabel: LocalizedStringKey, submitEnabled: (((String) -> Bool))? = nil, submit: @escaping () -> Void) -> some View {
PasscodeView(passcode: $passcode, title: title, reason: reason, submitLabel: submitLabel, submitEnabled: submitEnabled, buttonsEnabled: Binding.constant(true), submit: submit) { PasscodeView(passcode: $passcode, title: title, reason: reason, submitLabel: submitLabel, submitEnabled: submitEnabled, submit: submit) {
dismiss() dismiss()
cancel() cancel()
} }
@@ -9,20 +9,8 @@
import SwiftUI import SwiftUI
struct AddContactLearnMore: View { struct AddContactLearnMore: View {
var showTitle: Bool
var body: some View { var body: some View {
List { List {
if showTitle {
Text("One-time invitation link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Text("To connect, your contact can scan QR code or use the link in the app.") Text("To connect, your contact can scan QR code or use the link in the app.")
Text("If you can't meet in person, show QR code in a video call, or share the link.") Text("If you can't meet in person, show QR code in a video call, or share the link.")
@@ -35,6 +23,6 @@ struct AddContactLearnMore: View {
struct AddContactLearnMore_Previews: PreviewProvider { struct AddContactLearnMore_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AddContactLearnMore(showTitle: true) AddContactLearnMore()
} }
} }
@@ -0,0 +1,129 @@
//
// AddContactView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CoreImage.CIFilterBuiltins
import SimpleXChat
struct AddContactView: View {
@EnvironmentObject private var chatModel: ChatModel
@Binding var contactConnection: PendingContactConnection?
var connReqInvitation: String
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
VStack {
List {
Section {
if connReqInvitation != "" {
SimpleXLinkQRCode(uri: connReqInvitation)
} else {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(2)
.frame(maxWidth: .infinity)
.padding(.vertical)
}
IncognitoToggle(incognitoEnabled: $incognitoDefault)
.disabled(contactConnection == nil)
shareLinkButton(connReqInvitation)
oneTimeLinkLearnMoreButton()
} header: {
Text("1-time link")
} footer: {
sharedProfileInfo(incognitoDefault)
}
}
}
.onAppear { chatModel.connReqInv = connReqInvitation }
.onChange(of: incognitoDefault) { incognito in
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
}
}
}
}
struct IncognitoToggle: View {
@Binding var incognitoEnabled: Bool
@State private var showIncognitoSheet = false
var body: some View {
ZStack(alignment: .leading) {
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
.font(.system(size: 14))
Toggle(isOn: $incognitoEnabled) {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
showIncognitoSheet = true
}
}
.padding(.leading, 36)
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
}
func sharedProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
}
func shareLinkButton(_ connReqInvitation: String) -> some View {
Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: {
settingsRow("square.and.arrow.up") {
Text("Share 1-time link")
}
}
}
func oneTimeLinkLearnMoreButton() -> some View {
NavigationLink {
AddContactLearnMore()
.navigationTitle("One-time invitation link")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("info.circle") {
Text("Learn more")
}
}
}
struct AddContactView_Previews: PreviewProvider {
static var previews: some View {
AddContactView(
contactConnection: Binding.constant(PendingContactConnection.getSampleData()),
connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D"
)
}
}
@@ -130,10 +130,8 @@ struct AddGroupView: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { _ in LibraryImagePicker(image: $chosenImage) {
await MainActor.run { didSelectItem in showImagePicker = false
showImagePicker = false
}
} }
} }
.alert(isPresented: $showInvalidNameAlert) { .alert(isPresented: $showInvalidNameAlert) {
@@ -187,7 +185,6 @@ struct AddGroupView: View {
hideKeyboard() hideKeyboard()
do { do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task { Task {
let groupMembers = await apiListMembers(gInfo.groupId) let groupMembers = await apiListMembers(gInfo.groupId)
@@ -0,0 +1,42 @@
//
// ConnectViaLinkView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/09/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
enum ConnectViaLinkTab: String {
case scan
case paste
}
struct ConnectViaLinkView: View {
@State private var selection: ConnectViaLinkTab = connectViaLinkTabDefault.get()
var body: some View {
TabView(selection: $selection) {
ScanToConnectView()
.tabItem {
Label("Scan QR code", systemImage: "qrcode")
}
.tag(ConnectViaLinkTab.scan)
PasteToConnectView()
.tabItem {
Label("Paste received link", systemImage: "doc.plaintext")
}
.tag(ConnectViaLinkTab.paste)
}
.onChange(of: selection) { _ in
connectViaLinkTabDefault.set(selection)
}
}
}
struct ConnectViaLinkView_Previews: PreviewProvider {
static var previews: some View {
ConnectViaLinkView()
}
}
@@ -0,0 +1,93 @@
//
// CreateLinkView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/09/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum CreateLinkTab {
case oneTime
case longTerm
var title: LocalizedStringKey {
switch self {
case .oneTime: return "One-time invitation link"
case .longTerm: return "Your SimpleX address"
}
}
}
struct CreateLinkView: View {
@EnvironmentObject var m: ChatModel
@State var selection: CreateLinkTab
@State var connReqInvitation: String = ""
@State var contactConnection: PendingContactConnection? = nil
@State private var creatingConnReq = false
var viaNavLink = false
var body: some View {
if viaNavLink {
createLinkView()
} else {
NavigationView {
createLinkView()
}
}
}
private func createLinkView() -> some View {
TabView(selection: $selection) {
AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation)
.tabItem {
Label(
connReqInvitation == ""
? "Create one-time invitation link"
: "One-time invitation link",
systemImage: "1.circle"
)
}
.tag(CreateLinkTab.oneTime)
UserAddressView(viaCreateLinkView: true)
.tabItem {
Label("Your SimpleX address", systemImage: "infinity.circle")
}
.tag(CreateLinkTab.longTerm)
}
.onChange(of: selection) { _ in
if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq {
createInvitation()
}
}
.onAppear { m.connReqInv = connReqInvitation }
.onDisappear { m.connReqInv = nil }
.navigationTitle(selection.title)
.navigationBarTitleDisplayMode(.large)
}
private func createInvitation() {
creatingConnReq = true
Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
connReqInvitation = connReq
contactConnection = pcc
m.connReqInv = connReq
}
} else {
await MainActor.run {
creatingConnReq = false
}
}
}
}
}
struct CreateLinkView_Previews: PreviewProvider {
static var previews: some View {
CreateLinkView(selection: CreateLinkTab.oneTime)
}
}
@@ -0,0 +1,460 @@
//
// NewChatButton.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum NewChatAction: Identifiable {
case createLink(link: String, connection: PendingContactConnection)
case connectViaLink
case createGroup
var id: String {
switch self {
case let .createLink(link, _): return "createLink \(link)"
case .connectViaLink: return "connectViaLink"
case .createGroup: return "createGroup"
}
}
}
struct NewChatButton: View {
@Binding var showAddChat: Bool
@State private var actionSheet: NewChatAction?
var body: some View {
Button { showAddChat = true } label: {
Image(systemName: "square.and.pencil")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.confirmationDialog("Start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
Button("Share one-time invitation link") { addContactAction() }
Button("Connect via link / QR code") { actionSheet = .connectViaLink }
Button("Create secret group") { actionSheet = .createGroup }
}
.sheet(item: $actionSheet) { sheet in
switch sheet {
case let .createLink(link, pcc):
CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc)
case .connectViaLink: ConnectViaLinkView()
case .createGroup: AddGroupView()
}
}
}
func addContactAction() {
Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
actionSheet = .createLink(link: connReq, connection: pcc)
}
}
}
}
enum PlanAndConnectAlert: Identifiable {
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case invitationLinkConnecting(connectionLink: String)
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
var id: String {
switch self {
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
}
}
}
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
switch alert {
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own one-time link!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case .invitationLinkConnecting:
return Alert(
title: Text("Already connecting!"),
message: Text("You are already connecting via this one-time link!")
)
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own SimpleX address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat connection request?"),
message: Text("You have already requested connection via this address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Join group?"),
message: Text("You will connect to all group members."),
primaryButton: .default(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat join request?"),
message: Text("You are already joining the group via this link!"),
primaryButton: .destructive(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnecting(_, groupInfo):
if let groupInfo = groupInfo {
return Alert(
title: Text("Group already exists!"),
message: Text("You are already joining the group \(groupInfo.displayName).")
)
} else {
return Alert(
title: Text("Already joining the group!"),
message: Text("You are already joining the group via this link.")
)
}
}
}
enum PlanAndConnectActionSheet: Identifiable {
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
var id: String {
switch self {
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
}
}
}
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
switch sheet {
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
return ActionSheet(
title: Text("Connect with \(contact.chatViewName)"),
buttons: [
.default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false) },
.default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
if let incognito = incognito {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
}
}
}
func planAndConnect(
_ connectionLink: String,
showAlert: @escaping (PlanAndConnectAlert) -> Void,
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
dismiss: Bool,
incognito: Bool?
) {
Task {
do {
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
case .ok:
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
}
case .ownLink:
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
}
case let .connecting(contact_):
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
if let contact = contact_ {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
} else {
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
}
case let .known(contact):
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
case let .contactAddress(cap):
switch cap {
case .ok:
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
}
case .ownLink:
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
}
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
}
case let .connectingProhibit(contact):
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
case let .contactViaAddress(contact):
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
}
}
case let .groupLink(glp):
switch glp {
case .ok:
if let incognito = incognito {
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
}
case let .ownLink(groupInfo):
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
}
case let .connectingProhibit(groupInfo_):
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
}
}
} catch {
logger.debug("planAndConnect, plan error")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
}
}
}
}
private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool) {
Task {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
_ = await connectContactViaAddress(contact.contactId, incognito)
}
}
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
Task {
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
let crt: ConnReqType
if let plan = connectionPlan {
crt = planToConnReqType(plan)
} else {
crt = connReqType
}
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
} else {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
}
} else {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
}
}
}
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = c.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = c.id
showAlreadyExistsAlert?()
}
}
}
}
}
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let g = m.getGroupChat(groupInfo.groupId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = g.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = g.id
showAlreadyExistsAlert?()
}
}
}
}
}
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connecting to \(contact.displayName)."
)
}
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
mkAlert(
title: "Group already exists",
message: "You are already in group \(groupInfo.displayName)."
)
}
enum ConnReqType: Equatable {
case invitation
case contact
case groupLink
var connReqSentText: LocalizedStringKey {
switch self {
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
}
}
}
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
switch connectionPlan {
case .invitationLink: return .invitation
case .contactAddress: return .contact
case .groupLink: return .groupLink
}
}
func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert(
title: "Connection request sent!",
message: type.connReqSentText
)
}
struct NewChatButton_Previews: PreviewProvider {
static var previews: some View {
NewChatButton(showAddChat: Binding.constant(false))
}
}
@@ -1,52 +0,0 @@
//
// NewChatMenuButton.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.11.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
enum NewChatMenuOption: Identifiable {
case newContact
case newGroup
var id: Self { self }
}
struct NewChatMenuButton: View {
@Binding var newChatMenuOption: NewChatMenuOption?
var body: some View {
Menu {
Button {
newChatMenuOption = .newContact
} label: {
Text("Add contact")
}
Button {
newChatMenuOption = .newGroup
} label: {
Text("Create group")
}
} label: {
Image(systemName: "square.and.pencil")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .newGroup: AddGroupView()
}
}
}
}
#Preview {
NewChatMenuButton(
newChatMenuOption: Binding.constant(nil)
)
}
@@ -1,959 +0,0 @@
//
// NewChatView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.11.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
import CodeScanner
import AVFoundation
enum SomeAlert: Identifiable {
case someAlert(alert: Alert, id: String)
var id: String {
switch self {
case let .someAlert(_, id): return id
}
}
}
private enum NewChatViewAlert: Identifiable {
case planAndConnectAlert(alert: PlanAndConnectAlert)
case newChatSomeAlert(alert: SomeAlert)
var id: String {
switch self {
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)"
}
}
}
enum NewChatOption: Identifiable {
case invite
case connect
var id: Self { self }
}
struct NewChatView: View {
@EnvironmentObject var m: ChatModel
@State var selection: NewChatOption
@State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false
@State private var contactConnection: PendingContactConnection? = nil
@State private var connReqInvitation: String = ""
@State private var creatingConnReq = false
@State private var pastedLink: String = ""
@State private var alert: NewChatViewAlert?
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("New chat")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
Spacer()
InfoSheetButton {
AddContactLearnMore(showTitle: true)
}
}
.padding()
.padding(.top)
Picker("New chat", selection: $selection) {
Label("Add contact", systemImage: "link")
.tag(NewChatOption.invite)
Label("Connect via link", systemImage: "qrcode")
.tag(NewChatOption.connect)
}
.pickerStyle(.segmented)
.padding()
VStack {
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
// https://developer.apple.com/forums/thread/714977?answerId=731615022#731615022
if case .invite = selection {
prepareAndInviteView()
.transition(.move(edge: .leading))
.onAppear {
createInvitation()
}
}
if case .connect = selection {
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
.transition(.move(edge: .trailing))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
Rectangle()
.fill(Color(uiColor: .systemGroupedBackground))
)
.animation(.easeInOut(duration: 0.3333), value: selection)
.gesture(DragGesture(minimumDistance: 20.0, coordinateSpace: .local)
.onChanged { value in
switch(value.translation.width, value.translation.height) {
case (...0, -30...30): // left swipe
if selection == .invite {
selection = .connect
}
case (0..., -30...30): // right swipe
if selection == .connect {
selection = .invite
}
default: ()
}
}
)
}
.background(Color(.systemGroupedBackground))
.onChange(of: invitationUsed) { used in
if used && !(m.showingInvitation?.connChatUsed ?? true) {
m.markShowingInvitationUsed()
}
}
.onDisappear {
if !(m.showingInvitation?.connChatUsed ?? true),
let conn = contactConnection {
AlertManager.shared.showAlert(Alert(
title: Text("Keep unused invitation?"),
message: Text("You can view invitation link again in connection details."),
primaryButton: .default(Text("Keep")) {},
secondaryButton: .destructive(Text("Delete")) {
Task {
await deleteChat(Chat(
chatInfo: .contactConnection(contactConnection: conn),
chatItems: []
))
}
}
))
}
m.showingInvitation = nil
}
.alert(item: $alert) { a in
switch(a) {
case let .planAndConnectAlert(alert):
return planAndConnectAlert(alert, dismiss: true, cleanup: { pastedLink = "" })
case let .newChatSomeAlert(.someAlert(alert, _)):
return alert
}
}
}
private func prepareAndInviteView() -> some View {
ZStack { // ZStack is needed for views to not make transitions between each other
if connReqInvitation != "" {
InviteView(
invitationUsed: $invitationUsed,
contactConnection: $contactConnection,
connReqInvitation: connReqInvitation
)
} else if creatingConnReq {
creatingLinkProgressView()
} else {
retryButton()
}
}
}
private func createInvitation() {
if connReqInvitation == "" && contactConnection == nil && !creatingConnReq {
creatingConnReq = true
Task {
_ = try? await Task.sleep(nanoseconds: 250_000000)
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
if let (connReq, pcc) = r {
await MainActor.run {
m.updateContactConnection(pcc)
m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false)
connReqInvitation = connReq
contactConnection = pcc
}
} else {
await MainActor.run {
creatingConnReq = false
if let apiAlert = apiAlert {
alert = .newChatSomeAlert(alert: .someAlert(alert: apiAlert, id: "createInvitation error"))
}
}
}
}
}
}
// Rectangle here and in retryButton are needed for gesture to work
private func creatingLinkProgressView() -> some View {
ProgressView("Creating link…")
.progressViewStyle(.circular)
}
private func retryButton() -> some View {
Button(action: createInvitation) {
VStack(spacing: 6) {
Image(systemName: "arrow.counterclockwise")
Text("Retry")
}
}
}
}
private struct InviteView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection?
var connReqInvitation: String
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
List {
Section("Share this 1-time invite link") {
shareLinkView()
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView()
Section {
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
sharedProfileInfo(incognitoDefault)
}
}
.onChange(of: incognitoDefault) { incognito in
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
}
setInvitationUsed()
}
}
private func shareLinkView() -> some View {
HStack {
let link = simplexChatLink(connReqInvitation)
linkTextView(link)
Button {
showShareSheet(items: [link])
setInvitationUsed()
} label: {
Image(systemName: "square.and.arrow.up")
.padding(.top, -7)
}
}
.frame(maxWidth: .infinity)
}
private func qrCodeView() -> some View {
Section("Or show this code") {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
private func setInvitationUsed() {
if !invitationUsed {
invitationUsed = true
}
}
}
private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@State var showQRCodeScanner = false
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View {
List {
Section("Paste the link you received") {
pasteLinkView()
}
scanCodeView()
}
.actionSheet(item: $sheet) { s in
planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
}
.onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status
if showQRCodeScanner {
switch status {
case .notDetermined: askCameraAuthorization()
case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false
case .authorized: ()
@unknown default: askCameraAuthorization()
}
}
}
}
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
if allowed { cb?() }
}
}
@ViewBuilder private func pasteLinkView() -> some View {
if pastedLink == "" {
Button {
if let str = UIPasteboard.general.string {
if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
pastedLink = link.text
// It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
// https://github.com/twostraws/CodeScanner/issues/121
// No known tricks worked (changing view ID, wrapping it in another view, etc.)
// showQRCodeScanner = false
connect(pastedLink)
} else {
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
id: "pasteLinkView: code is not a SimpleX link"
))
}
}
} label: {
Text("Tap to paste link")
}
.disabled(!ChatModel.shared.pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
} else {
linkTextView(pastedLink)
}
}
private func scanCodeView() -> some View {
Section("Or scan QR code") {
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.padding(.horizontal)
} else {
Button {
switch cameraAuthorizationStatus {
case .notDetermined: askCameraAuthorization { showQRCodeScanner = true }
case .restricted: ()
case .denied: UIApplication.shared.open(appSettingsURL)
case .authorized: showQRCodeScanner = true
default: askCameraAuthorization { showQRCodeScanner = true }
}
} label: {
ZStack {
Rectangle()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(Color.clear)
switch cameraAuthorizationStatus {
case .restricted: Text("Camera not available")
case .denied: Label("Enable camera access", systemImage: "camera")
default: Label("Tap to scan", systemImage: "qrcode")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.disabled(cameraAuthorizationStatus == .restricted)
}
}
}
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
let link = r.string
if strIsSimplexLink(r.string) {
connect(link)
} else {
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
id: "processQRCode: code is not a SimpleX link"
))
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
id: "processQRCode: failure"
))
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
}
}
private func linkTextView(_ link: String) -> some View {
Text(link)
.lineLimit(1)
.font(.caption)
.truncationMode(.middle)
}
struct InfoSheetButton<Content: View>: View {
@ViewBuilder let content: Content
@State private var showInfoSheet = false
var body: some View {
Button {
showInfoSheet = true
} label: {
Image(systemName: "info.circle")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.sheet(isPresented: $showInfoSheet) {
content
}
}
}
func strIsSimplexLink(_ str: String) -> Bool {
if let parsedMd = parseSimpleXMarkdown(str),
parsedMd.count == 1,
case .simplexLink = parsedMd[0].format {
return true
} else {
return false
}
}
func strHasSingleSimplexLink(_ str: String) -> FormattedText? {
if let parsedMd = parseSimpleXMarkdown(str) {
let parsedLinks = parsedMd.filter({ $0.format?.isSimplexLink ?? false })
if parsedLinks.count == 1 {
return parsedLinks[0]
} else {
return nil
}
} else {
return nil
}
}
struct IncognitoToggle: View {
@Binding var incognitoEnabled: Bool
@State private var showIncognitoSheet = false
var body: some View {
ZStack(alignment: .leading) {
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
.font(.system(size: 14))
Toggle(isOn: $incognitoEnabled) {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
showIncognitoSheet = true
}
}
.padding(.leading, 36)
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
}
func sharedProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
}
enum PlanAndConnectAlert: Identifiable {
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case invitationLinkConnecting(connectionLink: String)
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
var id: String {
switch self {
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
}
}
}
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (() -> Void)? = nil) -> Alert {
switch alert {
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own one-time link!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
),
secondaryButton: .cancel() { cleanup?() }
)
case .invitationLinkConnecting:
return Alert(
title: Text("Already connecting!"),
message: Text("You are already connecting via this one-time link!"),
dismissButton: .default(Text("OK")) { cleanup?() }
)
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own SimpleX address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
),
secondaryButton: .cancel() { cleanup?() }
)
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat connection request?"),
message: Text("You have already requested connection via this address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
),
secondaryButton: .cancel() { cleanup?() }
)
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Join group?"),
message: Text("You will connect to all group members."),
primaryButton: .default(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
),
secondaryButton: .cancel() { cleanup?() }
)
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat join request?"),
message: Text("You are already joining the group via this link!"),
primaryButton: .destructive(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
),
secondaryButton: .cancel() { cleanup?() }
)
case let .groupLinkConnecting(_, groupInfo):
if let groupInfo = groupInfo {
return Alert(
title: Text("Group already exists!"),
message: Text("You are already joining the group \(groupInfo.displayName)."),
dismissButton: .default(Text("OK")) { cleanup?() }
)
} else {
return Alert(
title: Text("Already joining the group!"),
message: Text("You are already joining the group via this link."),
dismissButton: .default(Text("OK")) { cleanup?() }
)
}
}
}
enum PlanAndConnectActionSheet: Identifiable {
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
var id: String {
switch self {
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
}
}
}
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool, cleanup: (() -> Void)? = nil) -> ActionSheet {
switch sheet {
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
.cancel() { cleanup?() }
]
)
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
.cancel() { cleanup?() }
]
)
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
return ActionSheet(
title: Text("Connect with \(contact.chatViewName)"),
buttons: [
.default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) },
.default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) },
.cancel() { cleanup?() }
]
)
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
if let incognito = incognito {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) },
.cancel() { cleanup?() }
]
)
} else {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
.cancel() { cleanup?() }
]
)
}
}
}
func planAndConnect(
_ connectionLink: String,
showAlert: @escaping (PlanAndConnectAlert) -> Void,
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
dismiss: Bool,
incognito: Bool?,
cleanup: (() -> Void)? = nil,
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
Task {
do {
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
case .ok:
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
}
case .ownLink:
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
}
case let .connecting(contact_):
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
if let contact = contact_ {
if let f = filterKnownContact {
f(contact)
} else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
}
} else {
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
}
case let .known(contact):
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
if let f = filterKnownContact {
f(contact)
} else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
}
case let .contactAddress(cap):
switch cap {
case .ok:
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
}
case .ownLink:
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
}
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
}
case let .connectingProhibit(contact):
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
if let f = filterKnownContact {
f(contact)
} else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
}
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
if let f = filterKnownContact {
f(contact)
} else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
case let .contactViaAddress(contact):
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else {
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
}
}
case let .groupLink(glp):
switch glp {
case .ok:
if let incognito = incognito {
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
}
case let .ownLink(groupInfo):
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let f = filterKnownGroup {
f(groupInfo)
}
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
}
case let .connectingProhibit(groupInfo_):
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
if let f = filterKnownGroup {
f(groupInfo)
} else {
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
}
}
}
} catch {
logger.debug("planAndConnect, plan error")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
}
}
}
}
private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? = nil) {
Task {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
_ = await connectContactViaAddress(contact.contactId, incognito)
cleanup?()
}
}
private func connectViaLink(
_ connectionLink: String,
connectionPlan: ConnectionPlan?,
dismiss: Bool,
incognito: Bool,
cleanup: (() -> Void)?
) {
Task {
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
let crt: ConnReqType
if let plan = connectionPlan {
crt = planToConnReqType(plan)
} else {
crt = connReqType
}
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
} else {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
}
} else {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
}
cleanup?()
}
}
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = c.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = c.id
showAlreadyExistsAlert?()
}
}
}
}
}
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let g = m.getGroupChat(groupInfo.groupId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = g.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = g.id
showAlreadyExistsAlert?()
}
}
}
}
}
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connecting to \(contact.displayName)."
)
}
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
mkAlert(
title: "Group already exists",
message: "You are already in group \(groupInfo.displayName)."
)
}
enum ConnReqType: Equatable {
case invitation
case contact
case groupLink
var connReqSentText: LocalizedStringKey {
switch self {
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
}
}
}
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
switch connectionPlan {
case .invitationLink: return .invitation
case .contactAddress: return .contact
case .groupLink: return .groupLink
}
}
func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert(
title: "Connection request sent!",
message: type.connReqSentText
)
}
#Preview {
NewChatView(
selection: .invite
)
}
@@ -0,0 +1,106 @@
//
// PasteToConnectView.swift
// SimpleX (iOS)
//
// Created by Ian Davies on 22/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct PasteToConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@State private var connectionLink: String = ""
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@FocusState private var linkEditorFocused: Bool
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View {
List {
Text("Connect via link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.onTapGesture { linkEditorFocused = false }
Section {
linkEditor()
Button {
if connectionLink == "" {
connectionLink = UIPasteboard.general.string ?? ""
} else {
connectionLink = ""
}
} label: {
if connectionLink == "" {
settingsRow("doc.plaintext") { Text("Paste") }
} else {
settingsRow("multiply") { Text("Clear") }
}
}
Button {
connect()
} label: {
settingsRow("link") { Text("Connect") }
}
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
}
private func linkEditor() -> some View {
ZStack {
Group {
if connectionLink.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact.", comment: "placeholder")))
.foregroundColor(.secondary)
.disabled(true)
}
TextEditor(text: $connectionLink)
.onSubmit(connect)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($linkEditorFocused)
}
.allowsTightening(false)
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 180, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func connect() {
let link = connectionLink.trimmingCharacters(in: .whitespaces)
planAndConnect(
link,
showAlert: { alert = $0 },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: incognitoDefault
)
}
}
struct PasteToConnectView_Previews: PreviewProvider {
static var previews: some View {
PasteToConnectView()
}
}
+21 -17
View File
@@ -11,12 +11,20 @@ import CoreImage.CIFilterBuiltins
struct MutableQRCode: View { struct MutableQRCode: View {
@Binding var uri: String @Binding var uri: String
var withLogo: Bool = true @State private var image: UIImage?
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var body: some View { var body: some View {
QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor) ZStack {
.id("simplex-qrcode-view-for-\(uri)") if let image = image {
qrCodeImage(image)
}
}
.onAppear {
image = generateImage(uri)
}
.onChange(of: uri) { _ in
image = generateImage(uri)
}
} }
} }
@@ -24,10 +32,9 @@ struct SimpleXLinkQRCode: View {
let uri: String let uri: String
var withLogo: Bool = true var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var onShare: (() -> Void)? = nil
var body: some View { var body: some View {
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare) QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor)
} }
} }
@@ -41,9 +48,8 @@ struct QRCode: View {
let uri: String let uri: String
var withLogo: Bool = true var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var onShare: (() -> Void)? = nil
@State private var image: UIImage? = nil @State private var image: UIImage? = nil
@State private var makeScreenshotFunc: () -> Void = {} @State private var makeScreenshotBinding: () -> Void = {}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -64,20 +70,18 @@ struct QRCode: View {
} }
} }
.onAppear { .onAppear {
makeScreenshotFunc = { makeScreenshotBinding = {
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])}
onShare?()
}
} }
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
} }
} }
.onTapGesture(perform: makeScreenshotFunc) .onTapGesture(perform: makeScreenshotBinding)
.onAppear { .onAppear {
image = image ?? generateImage(uri, tintColor: tintColor) image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
@@ -89,13 +93,13 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled) .textSelection(.enabled)
} }
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? { private func generateImage(_ uri: String) -> UIImage? {
let context = CIContext() let context = CIContext()
let filter = CIFilter.qrCodeGenerator() let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8) filter.message = Data(uri.utf8)
if let outputImage = filter.outputImage, if let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor) return UIImage(cgImage: cgImage)
} }
return nil return nil
} }
@@ -0,0 +1,79 @@
//
// ConnectContactView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
import CodeScanner
struct ScanToConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Scan QR code")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical)
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
.padding(.horizontal)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .systemBackground))
)
.padding(.top)
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.horizontal)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(Color(.systemGroupedBackground))
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
planAndConnect(
r.string,
showAlert: { alert = $0 },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: incognitoDefault
)
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
}
}
}
struct ConnectContactView_Previews: PreviewProvider {
static var previews: some View {
ScanToConnectView()
}
}
@@ -11,14 +11,12 @@ import SimpleXChat
enum UserProfileAlert: Identifiable { enum UserProfileAlert: Identifiable {
case duplicateUserError case duplicateUserError
case invalidDisplayNameError
case createUserError(error: LocalizedStringKey) case createUserError(error: LocalizedStringKey)
case invalidNameError(validName: String) case invalidNameError(validName: String)
var id: String { var id: String {
switch self { switch self {
case .duplicateUserError: return "duplicateUserError" case .duplicateUserError: return "duplicateUserError"
case .invalidDisplayNameError: return "invalidDisplayNameError"
case .createUserError: return "createUserError" case .createUserError: return "createUserError"
case let .invalidNameError(validName): return "invalidNameError \(validName)" case let .invalidNameError(validName): return "invalidNameError \(validName)"
} }
@@ -189,12 +187,6 @@ private func createProfile(_ displayName: String, showAlert: (UserProfileAlert)
} else { } else {
showAlert(.duplicateUserError) showAlert(.duplicateUserError)
} }
case .chatCmdError(_, .error(.invalidDisplayName)):
if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else {
showAlert(.invalidDisplayNameError)
}
default: default:
let err: LocalizedStringKey = "Error: \(responseError(error))" let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil { if m.currentUser == nil {
@@ -215,7 +207,6 @@ private func canCreateProfile(_ displayName: String) -> Bool {
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert { func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
switch alert { switch alert {
case .duplicateUserError: return duplicateUserAlert case .duplicateUserError: return duplicateUserAlert
case .invalidDisplayNameError: return invalidDisplayNameAlert
case let .createUserError(err): return creatUserErrorAlert(err) case let .createUserError(err): return creatUserErrorAlert(err)
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName) case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
} }
@@ -228,13 +219,6 @@ private var duplicateUserAlert: Alert {
) )
} }
private var invalidDisplayNameAlert: Alert {
Alert(
title: Text("Invalid display name!"),
message: Text("This display name is invalid. Please choose another name.")
)
}
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
Alert( Alert(
title: Text("Error creating profile!"), title: Text("Error creating profile!"),
@@ -81,6 +81,11 @@ struct CreateSimpleXAddress: View {
DispatchQueue.main.async { DispatchQueue.main.async {
m.userAddress = UserContactLink(connReqContact: connReqContact) m.userAddress = UserContactLink(connReqContact: connReqContact)
} }
if let u = try await apiSetProfileAddress(on: true) {
DispatchQueue.main.async {
m.updateUser(u)
}
}
await MainActor.run { progressIndicator = false } await MainActor.run { progressIndicator = false }
} catch let error { } catch let error {
logger.error("CreateSimpleXAddress create address: \(responseError(error))") logger.error("CreateSimpleXAddress create address: \(responseError(error))")
@@ -95,7 +100,7 @@ struct CreateSimpleXAddress: View {
} label: { } label: {
Text("Create SimpleX address").font(.title) Text("Create SimpleX address").font(.title)
} }
Text("You can make it visible to your SimpleX contacts via Settings.") Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.font(.footnote) .font(.footnote)
.padding(.horizontal, 32) .padding(.horizontal, 32)
@@ -283,68 +283,6 @@ private let versionDescriptions: [VersionDescription] = [
), ),
] ]
), ),
VersionDescription(
version: "v5.4",
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
features: [
FeatureDescription(
icon: "desktopcomputer",
title: "Link mobile and desktop apps! 🔗",
description: "Via secure quantum resistant protocol."
),
FeatureDescription(
icon: "person.2",
title: "Better groups",
description: "Faster joining and more reliable messages."
),
FeatureDescription(
icon: "theatermasks",
title: "Incognito groups",
description: "Create a group using a random profile."
),
FeatureDescription(
icon: "hand.raised",
title: "Block group members",
description: "To hide unwanted messages."
),
FeatureDescription(
icon: "gift",
title: "A few more things",
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
),
]
),
VersionDescription(
version: "v5.5",
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
features: [
FeatureDescription(
icon: "folder",
title: "Private notes",
description: "With encrypted files and media."
),
FeatureDescription(
icon: "link",
title: "Paste link to connect!",
description: "Search bar accepts invitation links."
),
FeatureDescription(
icon: "bubble.left.and.bubble.right",
title: "Join group conversations",
description: "Recent history and improved [directory bot](simplex:/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)."
),
FeatureDescription(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
),
FeatureDescription(
icon: "character",
title: "Turkish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
]
)
] ]
private let lastVersion = versionDescriptions.last!.version private let lastVersion = versionDescriptions.last!.version
@@ -16,19 +16,12 @@ struct ConnectDesktopView: View {
var viaSettings = false var viaSettings = false
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) private var connectRemoteViaMulticastAuto = true @AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var sessionAddress: String = "" @State private var sessionAddress: String = ""
@State private var remoteCtrls: [RemoteCtrlInfo] = [] @State private var remoteCtrls: [RemoteCtrlInfo] = []
@State private var alert: ConnectDesktopAlert? @State private var alert: ConnectDesktopAlert?
@State private var showConnectScreen = true
@State private var showQRCodeScanner = true
@State private var firstAppearance = true
private var useMulticast: Bool {
connectRemoteViaMulticast && !remoteCtrls.isEmpty
}
private enum ConnectDesktopAlert: Identifiable { private enum ConnectDesktopAlert: Identifiable {
case unlinkDesktop(rc: RemoteCtrlInfo) case unlinkDesktop(rc: RemoteCtrlInfo)
@@ -74,14 +67,9 @@ struct ConnectDesktopView: View {
var viewBody: some View { var viewBody: some View {
Group { Group {
let discovery = m.remoteCtrlSession?.discovery if let session = m.remoteCtrlSession {
if discovery == true || (discovery == nil && !showConnectScreen) {
searchingDesktopView()
} else if let session = m.remoteCtrlSession {
switch session.sessionState { switch session.sessionState {
case .starting: connectingDesktopView(session, nil) case .starting: connectingDesktopView(session, nil)
case .searching: searchingDesktopView()
case let .found(rc, compatible): foundDesktopView(session, rc, compatible)
case let .connecting(rc_): connectingDesktopView(session, rc_) case let .connecting(rc_): connectingDesktopView(session, rc_)
case let .pendingConfirmation(rc_, sessCode): case let .pendingConfirmation(rc_, sessCode):
if confirmRemoteSessions || rc_ == nil { if confirmRemoteSessions || rc_ == nil {
@@ -93,35 +81,16 @@ struct ConnectDesktopView: View {
} }
case let .connected(rc, _): activeSessionView(session, rc) case let .connected(rc, _): activeSessionView(session, rc)
} }
// The hack below prevents camera freezing when exiting linked devices view.
// Using showQRCodeScanner inside connectDesktopView or passing it as parameter still results in freezing.
} else if showQRCodeScanner || firstAppearance {
connectDesktopView()
} else { } else {
connectDesktopView(showScanner: false) connectDesktopView()
} }
} }
.onAppear { .onAppear {
setDeviceName(deviceName) setDeviceName(deviceName)
updateRemoteCtrls() updateRemoteCtrls()
showConnectScreen = !useMulticast
if m.remoteCtrlSession != nil {
disconnectDesktop()
} else if useMulticast {
findKnownDesktop()
}
// The hack below prevents camera freezing when exiting linked devices view.
// `firstAppearance` prevents camera flicker when the view first opens.
// moving `showQRCodeScanner = false` to `onDisappear` (to avoid `firstAppearance`) does not prevent freeze.
showQRCodeScanner = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
firstAppearance = false
showQRCodeScanner = true
}
} }
.onDisappear { .onDisappear {
if m.remoteCtrlSession != nil { if m.remoteCtrlSession != nil {
showConnectScreen = false
disconnectDesktop() disconnectDesktop()
} }
} }
@@ -165,14 +134,12 @@ struct ConnectDesktopView: View {
.interactiveDismissDisabled(m.activeRemoteCtrl) .interactiveDismissDisabled(m.activeRemoteCtrl)
} }
private func connectDesktopView(showScanner: Bool = true) -> some View { private func connectDesktopView() -> some View {
List { List {
Section("This device name") { Section("This device name") {
devicesView() devicesView()
} }
if showScanner { scanDesctopAddressView()
scanDesctopAddressView()
}
if developerTools { if developerTools {
desktopAddressView() desktopAddressView()
} }
@@ -200,56 +167,6 @@ struct ConnectDesktopView: View {
.navigationTitle("Connecting to desktop") .navigationTitle("Connecting to desktop")
} }
private func searchingDesktopView() -> some View {
List {
Section("This device name") {
devicesView()
}
Section("Found desktop") {
Text("Waiting for desktop...").italic()
Button {
disconnectDesktop()
} label: {
Label("Scan QR code", systemImage: "qrcode")
}
}
}
.navigationTitle("Connecting to desktop")
}
@ViewBuilder private func foundDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo, _ compatible: Bool) -> some View {
let v = List {
Section("This device name") {
devicesView()
}
Section("Found desktop") {
ctrlDeviceNameText(session, rc)
ctrlDeviceVersionText(session)
if !compatible {
Text("Not compatible!").foregroundColor(.red)
} else if !connectRemoteViaMulticastAuto {
Button {
confirmKnownDesktop(rc)
} label: {
Label("Connect", systemImage: "checkmark")
}
}
}
if !compatible && !connectRemoteViaMulticastAuto {
Section {
disconnectButton("Cancel")
}
}
}
.navigationTitle("Found desktop")
if compatible && connectRemoteViaMulticastAuto {
v.onAppear { confirmKnownDesktop(rc) }
} else {
v
}
}
private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View { private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View {
List { List {
Section("Connected to desktop") { Section("Connected to desktop") {
@@ -274,7 +191,7 @@ struct ConnectDesktopView: View {
} }
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text { private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "") var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName)
if (rc == nil) { if (rc == nil) {
t = t + Text(" ") + Text("(new)").italic() t = t + Text(" ") + Text("(new)").italic()
} }
@@ -282,8 +199,8 @@ struct ConnectDesktopView: View {
} }
private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text { private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text {
let v = session.ctrlAppInfo?.appVersionRange.maxVersion let v = session.ctrlAppInfo.appVersionRange.maxVersion
var t = Text("v\(v ?? "")") var t = Text("v\(v)")
if v != session.appVersion { if v != session.appVersion {
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic() t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
} }
@@ -332,7 +249,7 @@ struct ConnectDesktopView: View {
private func scanDesctopAddressView() -> some View { private func scanDesctopAddressView() -> some View {
Section("Scan QR code from desktop") { Section("Scan QR code from desktop") {
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode) CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -384,10 +301,7 @@ struct ConnectDesktopView: View {
Section("Linked desktop options") { Section("Linked desktop options") {
Toggle("Verify connections", isOn: $confirmRemoteSessions) Toggle("Verify connections", isOn: $confirmRemoteSessions)
Toggle("Discover via local network", isOn: $connectRemoteViaMulticast) Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true)
if connectRemoteViaMulticast {
Toggle("Connect automatically", isOn: $connectRemoteViaMulticastAuto)
}
} }
} }
.navigationTitle("Linked desktops") .navigationTitle("Linked desktops")
@@ -421,42 +335,10 @@ struct ConnectDesktopView: View {
} }
} }
private func findKnownDesktop() {
Task {
do {
try await findKnownRemoteCtrl()
await MainActor.run {
m.remoteCtrlSession = RemoteCtrlSession(
ctrlAppInfo: nil,
appVersion: "",
sessionState: .searching
)
showConnectScreen = true
}
} catch let e {
await MainActor.run {
errorAlert(e)
}
}
}
}
private func confirmKnownDesktop(_ rc: RemoteCtrlInfo) {
connectDesktop_ {
try await confirmRemoteCtrl(rc.remoteCtrlId)
}
}
private func connectDesktopAddress(_ addr: String) { private func connectDesktopAddress(_ addr: String) {
connectDesktop_ {
try await connectRemoteCtrl(desktopAddress: addr)
}
}
private func connectDesktop_(_ connect: @escaping () async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String)) {
Task { Task {
do { do {
let (rc_, ctrlAppInfo, v) = try await connect() let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr)
await MainActor.run { await MainActor.run {
sessionAddress = "" sessionAddress = ""
m.remoteCtrlSession = RemoteCtrlSession( m.remoteCtrlSession = RemoteCtrlSession(
@@ -498,11 +380,11 @@ struct ConnectDesktopView: View {
} }
} }
private func disconnectButton(_ label: LocalizedStringKey = "Disconnect") -> some View { private func disconnectButton() -> some View {
Button { Button {
disconnectDesktop(.dismiss) disconnectDesktop()
} label: { } label: {
Label(label, systemImage: "multiply") Label("Disconnect", systemImage: "multiply")
} }
} }
@@ -511,11 +393,7 @@ struct ConnectDesktopView: View {
do { do {
try await stopRemoteCtrl() try await stopRemoteCtrl()
await MainActor.run { await MainActor.run {
if case .connected = m.remoteCtrlSession?.sessionState { switchToLocalSession()
switchToLocalSession()
} else {
m.remoteCtrlSession = nil
}
switch action { switch action {
case .back: dismiss() case .back: dismiss()
case .dismiss: dismiss() case .dismiss: dismiss()
@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
} }
.disabled(currentNetCfg == NetCfg.proxyDefaults) .disabled(currentNetCfg == NetCfg.proxyDefaults)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -10,23 +10,24 @@ import SwiftUI
struct IncognitoHelp: View { struct IncognitoHelp: View {
var body: some View { var body: some View {
List { VStack(alignment: .leading) {
Text("Incognito mode") Text("Incognito mode")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical) .padding(.vertical)
.listRowBackground(Color.clear) ScrollView {
.listRowSeparator(.hidden) VStack(alignment: .leading) {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) Group {
VStack(alignment: .leading, spacing: 18) { Text("Incognito mode protects your privacy by using a new random profile for each contact.")
Text("Incognito mode protects your privacy by using a new random profile for each contact.") Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") }
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).") .padding(.bottom)
}
} }
.listRowBackground(Color.clear)
} }
.frame(maxWidth: .infinity)
.padding()
} }
} }
@@ -14,6 +14,9 @@ struct NotificationsView: View {
@State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode
@State private var showAlert: NotificationAlert? @State private var showAlert: NotificationAlert?
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false
var body: some View { var body: some View {
List { List {
@@ -76,10 +79,6 @@ struct NotificationsView: View {
Text(m.notificationPreview.label) Text(m.notificationPreview.label)
} }
} }
if let server = m.notificationServer {
smpServers("Push server", [server])
}
} header: { } header: {
Text("Push notifications") Text("Push notifications")
} footer: { } footer: {
@@ -89,11 +88,15 @@ struct NotificationsView: View {
.padding(.top, 1) .padding(.top, 1)
} }
} }
// if developerTools {
// Section(String("Experimental")) {
// Toggle(String("Always enable local"), isOn: $ntfEnableLocal)
// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic)
// }
// }
} }
.disabled(legacyDatabase) .disabled(legacyDatabase)
.onAppear {
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
}
} }
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert { private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
@@ -116,7 +119,7 @@ struct NotificationsView: View {
private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey { private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode { switch mode {
case .off: return "Use only local notifications?" case .off: return "Turn off notifications?"
case .periodic: return "Enable periodic notifications?" case .periodic: return "Enable periodic notifications?"
case .instant: return "Enable instant notifications?" case .instant: return "Enable instant notifications?"
} }
@@ -132,7 +135,6 @@ struct NotificationsView: View {
m.tokenStatus = .new m.tokenStatus = .new
notificationMode = .off notificationMode = .off
m.notificationMode = .off m.notificationMode = .off
m.notificationServer = nil
} }
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
@@ -143,13 +145,11 @@ struct NotificationsView: View {
} }
default: default:
do { do {
let _ = try await apiRegisterToken(token: token, notificationMode: mode) let status = try await apiRegisterToken(token: token, notificationMode: mode)
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
await MainActor.run { await MainActor.run {
m.tokenStatus = tknStatus m.tokenStatus = status
notificationMode = ntfMode notificationMode = mode
m.notificationMode = ntfMode m.notificationMode = mode
m.notificationServer = ntfServer
} }
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
@@ -63,6 +63,7 @@ struct PreferencesView: View {
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View { private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
Text(feature.allowDescription(allowFeature.wrappedValue)) Text(feature.allowDescription(allowFeature.wrappedValue))
.frame(height: 36, alignment: .topLeading)
} }
private func savePreferences() { private func savePreferences() {
@@ -467,7 +467,6 @@ struct SimplexLockView: View {
switch a { switch a {
case .enableAuth: case .enableAuth:
SetAppPasscodeView { SetAppPasscodeView {
m.contentViewAccessAuthenticated = true
laLockDelay = 30 laLockDelay = 30
prefPerformLA = true prefPerformLA = true
showChangePassword = true showChangePassword = true
@@ -491,23 +490,14 @@ struct SimplexLockView: View {
showLAAlert(.laPasscodeNotChangedAlert) showLAAlert(.laPasscodeNotChangedAlert)
} }
case .enableSelfDestruct: case .enableSelfDestruct:
SetAppPasscodeView( SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, title: "Set passcode", reason: NSLocalizedString("Enable self-destruct passcode", comment: "set passcode view")) {
passcodeKeychain: kcSelfDestructPassword,
prohibitedPasscodeKeychain: kcAppPassword,
title: "Set passcode",
reason: NSLocalizedString("Enable self-destruct passcode", comment: "set passcode view")
) {
updateSelfDestruct() updateSelfDestruct()
showLAAlert(.laSelfDestructPasscodeSetAlert) showLAAlert(.laSelfDestructPasscodeSetAlert)
} cancel: { } cancel: {
revertSelfDestruct() revertSelfDestruct()
} }
case .changeSelfDestructPasscode: case .changeSelfDestructPasscode:
SetAppPasscodeView( SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, reason: NSLocalizedString("Change self-destruct passcode", comment: "set passcode view")) {
passcodeKeychain: kcSelfDestructPassword,
prohibitedPasscodeKeychain: kcAppPassword,
reason: NSLocalizedString("Change self-destruct passcode", comment: "set passcode view")
) {
showLAAlert(.laSelfDestructPasscodeChangedAlert) showLAAlert(.laSelfDestructPasscodeChangedAlert)
} cancel: { } cancel: {
showLAAlert(.laPasscodeNotChangedAlert) showLAAlert(.laPasscodeNotChangedAlert)
@@ -629,7 +619,6 @@ struct SimplexLockView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: case .success:
m.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
laAlert = .laTurnedOnAlert laAlert = .laTurnedOnAlert
case .failed: case .failed:
@@ -21,7 +21,7 @@ struct ScanProtocolServer: View {
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.padding(.vertical) .padding(.vertical)
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
.padding(.top) .padding(.top)
@@ -56,7 +56,7 @@ let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast"
let appDefaults: [String: Any] = [ let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_SHOW_LA_NOTICE: false,
@@ -91,16 +91,10 @@ let appDefaults: [String: Any] = [
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false,
DEFAULT_CONFIRM_REMOTE_SESSIONS: false, DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, DEFAULT_OFFER_REMOTE_MULTICAST: true
] ]
// not used anymore
enum ConnectViaLinkTab: String {
case scan
case paste
}
enum SimpleXLinkMode: String, Identifiable { enum SimpleXLinkMode: String, Identifiable {
case description case description
case full case full
@@ -159,42 +153,37 @@ struct SettingsView: View {
} }
@ViewBuilder func settingsView() -> some View { @ViewBuilder func settingsView() -> some View {
let user = chatModel.currentUser let user: User = chatModel.currentUser!
NavigationView { NavigationView {
List { List {
Section("You") { Section("You") {
if let user = user { NavigationLink {
NavigationLink { UserProfile()
UserProfile() .navigationTitle("Your current profile")
.navigationTitle("Your current profile") } label: {
} label: { ProfilePreview(profileOf: user)
ProfilePreview(profileOf: user) .padding(.leading, -8)
.padding(.leading, -8)
}
} }
NavigationLink { NavigationLink {
UserProfilesView(showSettings: $showSettings) UserProfilesView()
} label: { } label: {
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
} }
NavigationLink {
UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode") { Text("Your SimpleX address") }
}
if let user = user { NavigationLink {
NavigationLink { PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
UserAddressView(shareViaProfile: user.addressShared) .navigationTitle("Your preferences")
.navigationTitle("SimpleX address") } label: {
.navigationBarTitleDisplayMode(.large) settingsRow("switch.2") { Text("Chat preferences") }
} label: {
settingsRow("qrcode") { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
} label: {
settingsRow("switch.2") { Text("Chat preferences") }
}
} }
NavigationLink { NavigationLink {
@@ -255,14 +244,12 @@ struct SettingsView: View {
} }
Section("Help") { Section("Help") {
if let user = user { NavigationLink {
NavigationLink { ChatHelp(showSettings: $showSettings)
ChatHelp(showSettings: $showSettings) .navigationTitle("Welcome \(user.displayName)!")
.navigationTitle("Welcome \(user.displayName)!") .frame(maxHeight: .infinity, alignment: .top)
.frame(maxHeight: .infinity, alignment: .top) } label: {
} label: { settingsRow("questionmark") { Text("How to use it") }
settingsRow("questionmark") { Text("How to use it") }
}
} }
NavigationLink { NavigationLink {
WhatsNewView(viaSettings: true) WhatsNewView(viaSettings: true)
@@ -190,8 +190,7 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section { Section {
SimpleXLinkQRCode(uri: userAddress.connReqContact) MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact)))
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
if MFMailComposeViewController.canSendMail() { if MFMailComposeViewController.canSendMail() {
shareViaEmailButton(userAddress) shareViaEmailButton(userAddress)
@@ -120,10 +120,8 @@ struct UserProfile: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { _ in LibraryImagePicker(image: $chosenImage) {
await MainActor.run { didSelectItem in showImagePicker = false
showImagePicker = false
}
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
@@ -8,7 +8,6 @@ import SimpleXChat
struct UserProfilesView: View { struct UserProfilesView: View {
@EnvironmentObject private var m: ChatModel @EnvironmentObject private var m: ChatModel
@Binding var showSettings: Bool
@Environment(\.editMode) private var editMode @Environment(\.editMode) private var editMode
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
@@ -26,6 +25,7 @@ struct UserProfilesView: View {
private enum UserProfilesAlert: Identifiable { private enum UserProfilesAlert: Identifiable {
case deleteUser(user: User, delSMPQueues: Bool) case deleteUser(user: User, delSMPQueues: Bool)
case cantDeleteLastUser
case hiddenProfilesNotice case hiddenProfilesNotice
case muteProfileAlert case muteProfileAlert
case activateUserError(error: String) case activateUserError(error: String)
@@ -34,6 +34,7 @@ struct UserProfilesView: View {
var id: String { var id: String {
switch self { switch self {
case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)"
case .cantDeleteLastUser: return "cantDeleteLastUser"
case .hiddenProfilesNotice: return "hiddenProfilesNotice" case .hiddenProfilesNotice: return "hiddenProfilesNotice"
case .muteProfileAlert: return "muteProfileAlert" case .muteProfileAlert: return "muteProfileAlert"
case let .activateUserError(err): return "activateUserError \(err)" case let .activateUserError(err): return "activateUserError \(err)"
@@ -77,7 +78,7 @@ struct UserProfilesView: View {
Section { Section {
let users = filteredUsers() let users = filteredUsers()
let v = ForEach(users) { u in let v = ForEach(users) { u in
userView(u.user) userView(u.user, allowDelete: users.count > 1)
} }
if #available(iOS 16, *) { if #available(iOS 16, *) {
v.onDelete { indexSet in v.onDelete { indexSet in
@@ -145,6 +146,13 @@ struct UserProfilesView: View {
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
case .cantDeleteLastUser:
return Alert(
title: Text("Can't delete user profile!"),
message: m.users.count > 1
? Text("There should be at least one visible user profile.")
: Text("There should be at least one user profile.")
)
case .hiddenProfilesNotice: case .hiddenProfilesNotice:
return Alert( return Alert(
title: Text("Make profile private!"), title: Text("Make profile private!"),
@@ -272,21 +280,11 @@ struct UserProfilesView: View {
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
try await deleteUser() try await deleteUser()
} else {
// Deleting the last visible user while having hidden one(s)
try await deleteUser()
try await changeActiveUserAsync_(nil, viewPwd: nil)
await MainActor.run {
onboardingStageDefault.set(.step1_SimpleXInfo)
m.onboardingStage = .step1_SimpleXInfo
showSettings = false
}
} }
} else { } else {
try await deleteUser() try await deleteUser()
} }
} catch let error { } catch let error {
logger.error("Error deleting user profile: \(error)")
let a = getErrorAlert(error, "Error deleting user profile") let a = getErrorAlert(error, "Error deleting user profile")
alert = .error(title: a.title, error: a.message) alert = .error(title: a.title, error: a.message)
} }
@@ -297,7 +295,7 @@ struct UserProfilesView: View {
} }
} }
@ViewBuilder private func userView(_ user: User) -> some View { @ViewBuilder private func userView(_ user: User, allowDelete: Bool) -> some View {
let v = Button { let v = Button {
Task { Task {
do { do {
@@ -325,7 +323,9 @@ struct UserProfilesView: View {
} }
} }
} }
.disabled(user.activeUser)
.foregroundColor(.primary) .foregroundColor(.primary)
.deleteDisabled(!allowDelete)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden { if user.hidden {
Button("Unhide") { Button("Unhide") {
@@ -361,6 +361,8 @@ struct UserProfilesView: View {
} }
if #available(iOS 16, *) { if #available(iOS 16, *) {
v v
} else if !allowDelete {
v
} else { } else {
v.swipeActions(edge: .trailing, allowsFullSwipe: true) { v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
@@ -371,8 +373,12 @@ struct UserProfilesView: View {
} }
private func confirmDeleteUser(_ user: User) { private func confirmDeleteUser(_ user: User) {
showDeleteConfirmation = true if m.users.count > 1 && (user.hidden || visibleUsersCount > 1) {
userToDelete = user showDeleteConfirmation = true
userToDelete = user
} else {
alert = .cantDeleteLastUser
}
} }
private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) { private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) {
@@ -403,6 +409,6 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
struct UserProfilesView_Previews: PreviewProvider { struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true)) UserProfilesView()
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -31,59 +31,48 @@ Available in v5.1</source>
<source> (</source> <source> (</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id=" (can be copied)" xml:space="preserve" approved="no"> <trans-unit id=" (can be copied)" xml:space="preserve">
<source> (can be copied)</source> <source> (can be copied)</source>
<target state="translated"> (μπορεί να αντιγραφή)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="!1 colored!" xml:space="preserve" approved="no"> <trans-unit id="!1 colored!" xml:space="preserve">
<source>!1 colored!</source> <source>!1 colored!</source>
<target state="translated">!1 έγχρωμο!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="#secret#" xml:space="preserve" approved="no"> <trans-unit id="#secret#" xml:space="preserve">
<source>#secret#</source> <source>#secret#</source>
<target state="translated">#μυστικό#</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@" xml:space="preserve" approved="no"> <trans-unit id="%@" xml:space="preserve">
<source>%@</source> <source>%@</source>
<target state="translated">%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ %@" xml:space="preserve" approved="no"> <trans-unit id="%@ %@" xml:space="preserve">
<source>%@ %@</source> <source>%@ %@</source>
<target state="translated">%@ %@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ / %@" xml:space="preserve" approved="no"> <trans-unit id="%@ / %@" xml:space="preserve">
<source>%@ / %@</source> <source>%@ / %@</source>
<target state="translated">%@ / %@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ is connected!" xml:space="preserve" approved="no"> <trans-unit id="%@ is connected!" xml:space="preserve">
<source>%@ is connected!</source> <source>%@ is connected!</source>
<target state="translated">%@ είναι συνδεδεμένο!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ is not verified" xml:space="preserve" approved="no"> <trans-unit id="%@ is not verified" xml:space="preserve">
<source>%@ is not verified</source> <source>%@ is not verified</source>
<target state="translated">%@ δεν είναι επαληθευμένο</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ is verified" xml:space="preserve" approved="no"> <trans-unit id="%@ is verified" xml:space="preserve">
<source>%@ is verified</source> <source>%@ is verified</source>
<target state="translated">%@ είναι επαληθευμένο</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ servers" xml:space="preserve" approved="no"> <trans-unit id="%@ servers" xml:space="preserve">
<source>%@ servers</source> <source>%@ servers</source>
<target state="translated">%@ διακομιστές</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ wants to connect!" xml:space="preserve" approved="no"> <trans-unit id="%@ wants to connect!" xml:space="preserve">
<source>%@ wants to connect!</source> <source>%@ wants to connect!</source>
<target state="translated">%@ θέλει να συνδεθεί!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%d days" xml:space="preserve"> <trans-unit id="%d days" xml:space="preserve">
@@ -4173,66 +4162,6 @@ SimpleX servers cannot see your profile.</source>
<source>\~strike~</source> <source>\~strike~</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ connected" xml:space="preserve" approved="no">
<source>%@ connected</source>
<target state="translated">%@ συνδεδεμένο</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="# %@" xml:space="preserve" approved="no">
<source># %@</source>
<target state="translated"># %@</target>
<note>copied message info title, # &lt;title&gt;</note>
</trans-unit>
<trans-unit id="%@ and %@" xml:space="preserve" approved="no">
<source>%@ and %@</source>
<target state="translated">%@ και %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ at %@:" xml:space="preserve" approved="no">
<source>%1$@ at %2$@:</source>
<target state="translated">%1$@ στις %2$@:</target>
<note>copied message info, &lt;sender&gt; at &lt;time&gt;</note>
</trans-unit>
<trans-unit id="## History" xml:space="preserve" approved="no">
<source>## History</source>
<target state="translated">## Ιστορικό</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
<source>## In reply to</source>
<target state="translated">## Ως απαντηση σε</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
<source>%@ (current)</source>
<target state="translated">%@ (τωρινό)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
<source>%@ (current):</source>
<target state="translated">%@ (τωρινό):</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
<source>%@ and %@ connected</source>
<target state="translated">%@ και %@ συνδεδεμένο</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@:" xml:space="preserve" approved="no">
<source>%@:</source>
<target state="translated">%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve" approved="no">
<source>%@, %@ and %lld members</source>
<target state="translated">%@, %@ και %lld μέλη</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld other members connected" xml:space="preserve" approved="no">
<source>%@, %@ and %lld other members connected</source>
<target state="translated">%@, %@ και %lld άλλα μέλη συνδέθηκαν</target>
<note>No comment provided by engineer.</note>
</trans-unit>
</body> </body>
</file> </file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="el" datatype="plaintext"> <file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="el" datatype="plaintext">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,15 +0,0 @@
{
"colors" : [
{
"idiom" : "universal",
"locale" : "tr"
}
],
"properties" : {
"localizable" : true
},
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "0.000",
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.533"
}
},
"idiom" : "universal"
}
],
"properties" : {
"localizable" : true
},
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,6 +0,0 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,30 +0,0 @@
/* No comment provided by engineer. */
"_italic_" = "\\_italic_";
/* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
/* No comment provided by engineer. */
"*bold*" = "\\*bold*";
/* No comment provided by engineer. */
"`a + b`" = "\\`a + b`";
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* call status */
"connecting call" = "connecting call…";
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
/* No comment provided by engineer. */
"Connecting server… (error: %@)" = "Connecting to server… (error: %@)";
/* rcv group event chat item */
"member connected" = "connected";
/* No comment provided by engineer. */
"No group!" = "Group not found!";
@@ -1,12 +0,0 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -1,12 +0,0 @@
{
"developmentRegion" : "en",
"project" : "SimpleX.xcodeproj",
"targetLocale" : "tr",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
},
"version" : "1.0"
}

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