Compare commits
1 Commits
v5.5.2-fdr
...
ep/rfc-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0366afbcce |
257
.github/workflows/build.yml
vendored
257
.github/workflows/build.yml
vendored
@@ -8,8 +8,6 @@ on:
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
- "!*-armv7a"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -43,7 +41,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build:
|
||||
name: build-${{ matrix.os }}-${{ matrix.ghc }}
|
||||
name: build-${{ matrix.os }}
|
||||
if: always()
|
||||
needs: prepare-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -52,28 +50,17 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
ghc: "8.10.7"
|
||||
cache_path: ~/.cabal/store
|
||||
- os: ubuntu-20.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
- os: macos-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
steps:
|
||||
- name: Configure pagefile (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -87,19 +74,18 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell-actions/setup@v2
|
||||
uses: haskell/actions/setup@v2
|
||||
with:
|
||||
ghc-version: ${{ matrix.ghc }}
|
||||
cabal-version: "3.10.1.0"
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
|
||||
- name: Restore cached build
|
||||
id: restore_cache
|
||||
uses: actions/cache/restore@v3
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
|
||||
# / Unix
|
||||
|
||||
@@ -113,10 +99,6 @@ jobs:
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install pkg-config
|
||||
@@ -129,145 +111,23 @@ jobs:
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix build CLI
|
||||
id: unix_cli_build
|
||||
- name: Unix build
|
||||
id: unix_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
path=$(cabal list-bin simplex-chat)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix upload CLI binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||
- name: Unix upload binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.unix_cli_build.outputs.bin_path }}
|
||||
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Unix update CLI binary hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.unix_cli_build.outputs.bin_hash }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Linux build desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-linux.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageDeb
|
||||
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux make AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
|
||||
echo "appimage_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Mac build desktop
|
||||
id: mac_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
||||
run: |
|
||||
scripts/ci/build-desktop-mac.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.linux_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Linux upload AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
||||
asset_name: simplex-desktop-x86_64.AppImage
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update AppImage hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
||||
|
||||
- name: Mac upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.mac_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Mac update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.mac_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Cache unix build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os != 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
@@ -277,47 +137,21 @@ jobs:
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
# 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'
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: ucrt64
|
||||
update: true
|
||||
install: >-
|
||||
git
|
||||
perl
|
||||
make
|
||||
pacboy: >-
|
||||
toolchain:p
|
||||
cmake:p
|
||||
|
||||
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: msys2 {0}
|
||||
shell: cmd
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||
scripts/desktop/prepare-openssl-windows.sh
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
rm cabal.project.local 2>/dev/null || true
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
|
||||
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
||||
cabal build --enable-tests
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
cabal list-bin simplex-chat > tmp_bin_path
|
||||
set /p bin_path= < tmp_bin_path
|
||||
echo ::set-output name=bin_path::%bin_path%
|
||||
|
||||
- name: Windows upload CLI binary to release
|
||||
- name: Windows upload binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
@@ -326,55 +160,4 @@ jobs:
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Windows update CLI binary hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.windows_build.outputs.bin_hash }}
|
||||
|
||||
- name: Windows build desktop
|
||||
id: windows_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||
scripts/desktop/build-lib-windows.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageMsi
|
||||
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Windows upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.windows_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Windows update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.windows_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Cache windows build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os == 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
# Windows /
|
||||
|
||||
1
.github/workflows/web.yml
vendored
1
.github/workflows/web.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
- website/**
|
||||
- images/**
|
||||
- blog/**
|
||||
- docs/**
|
||||
- .github/workflows/web.yml
|
||||
|
||||
jobs:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,7 +53,6 @@ website/src/docs/
|
||||
website/translations.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 9.6.3
|
||||
RUN ghcup install ghc 8.10.7
|
||||
# Install cabal
|
||||
RUN ghcup install cabal 3.10.1.0
|
||||
RUN ghcup install cabal
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 9.6.3 && \
|
||||
ghcup set cabal 3.10.1.0
|
||||
RUN ghcup set ghc 8.10.7 && \
|
||||
ghcup set cabal
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
|
||||
70
PRIVACY.md
70
PRIVACY.md
@@ -6,63 +6,27 @@ If you believe that some of the clauses in this document are not aligned with ou
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Information you provide
|
||||
|
||||
#### User profiles
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device.
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
|
||||
|
||||
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
#### Messages and Files
|
||||
|
||||
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
||||
|
||||
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
|
||||
|
||||
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
||||
|
||||
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
|
||||
|
||||
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
|
||||
|
||||
#### Connections with other users
|
||||
|
||||
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
|
||||
|
||||
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
||||
|
||||
#### iOS Push Notifications
|
||||
|
||||
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
||||
|
||||
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
|
||||
|
||||
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
#### Another information stored on the servers
|
||||
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
#### SimpleX Directory Service
|
||||
|
||||
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
||||
|
||||
#### User Support.
|
||||
|
||||
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||
|
||||
### Information we may share
|
||||
|
||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||
|
||||
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
|
||||
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||
|
||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||
|
||||
@@ -75,19 +39,19 @@ At the time of updating this document, we have never provided or have been reque
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
|
||||
Please also read our Terms of Service below.
|
||||
Please also read our Terms of Service.
|
||||
|
||||
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
||||
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
|
||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
@@ -103,17 +67,15 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||
|
||||
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
|
||||
|
||||
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
|
||||
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||
|
||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||
|
||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||
|
||||
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
|
||||
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
|
||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||
|
||||
@@ -131,4 +93,4 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated August 17, 2023
|
||||
Updated November 8, 2022
|
||||
|
||||
107
README.md
107
README.md
@@ -2,7 +2,7 @@
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
<a rel="me" href="https://mastodon.social/@simplex"></a>
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
1. 📲 [Install the app](#install-the-app).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||
@@ -40,29 +40,21 @@
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||
|
||||
## Connect to the team
|
||||
|
||||
You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to:
|
||||
## Connect to the team via the app
|
||||
|
||||
- to ask any questions
|
||||
- to suggest any improvements
|
||||
- to share anything relevant
|
||||
|
||||
We are replying the questions manually, so it is not instant – it can take up to 24 hours.
|
||||
|
||||
If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch.
|
||||
|
||||
## Join user groups
|
||||
|
||||
You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
|
||||
|
||||
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||
|
||||
You also can:
|
||||
- criticize the app, and make comparisons with other messengers.
|
||||
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||
- share some privacy related publications, infrequently.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||
|
||||
You must:
|
||||
- be polite to other users
|
||||
@@ -72,7 +64,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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -87,15 +79,6 @@ There are groups in other languages, that we have the apps interface translated
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
## Follow our updates
|
||||
|
||||
We publish our updates and releases via:
|
||||
|
||||
- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
|
||||
- SimpleX Chat [team profile](#connect-to-the-team).
|
||||
- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss).
|
||||
- [mailing list](https://simplex.chat/#join-simplex), very rarely.
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||
@@ -119,24 +102,17 @@ Join our translators to help SimpleX grow!
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
||||
|🇭🇺 hu|Magyar | |[](https://hosted.weblate.org/projects/simplex-chat/android/hu/)<br>-|||
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
||||
|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
||||
|🇹🇷 tr|Türkçe | |[](https://hosted.weblate.org/projects/simplex-chat/android/tr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)|||
|
||||
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](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)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
@@ -164,14 +140,11 @@ It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- USDT:
|
||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
|
||||
Thank you,
|
||||
|
||||
@@ -191,7 +164,7 @@ SimpleX Chat founder
|
||||
- [News and updates](#news-and-updates)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
@@ -232,22 +205,22 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## News and 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).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
Recent updates:
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
|
||||
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
|
||||
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
@@ -280,7 +253,7 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/p
|
||||
|
||||
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
||||
|
||||
## Privacy and security: technical details and limitations
|
||||
## Privacy: technical details and limitations
|
||||
|
||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||
|
||||
@@ -293,23 +266,18 @@ What is already implemented:
|
||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
14. Local files encryption.
|
||||
|
||||
We plan to add:
|
||||
We plan to add soon:
|
||||
|
||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -369,28 +337,23 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Message reactions
|
||||
- ✅ Message editing history
|
||||
- ✅ Reduced battery and traffic usage in large groups.
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- ✅ Desktop client.
|
||||
- ✅ Encryption of local files stored in the app.
|
||||
- ✅ Using mobile profiles from the desktop app.
|
||||
- ✅ Private notes.
|
||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||
- 🏗 Improve experience for the new users.
|
||||
- 🏗 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.
|
||||
- 🏗 Desktop client.
|
||||
- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Local app files encryption.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Large groups, communities and public channels.
|
||||
- Feeds/broadcasts.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Privately share your location.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
@@ -14,40 +14,15 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func trackKeyboard() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
@objc func pasteboardChanged() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
let m = ChatModel.shared
|
||||
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
|
||||
m.deviceToken = deviceToken
|
||||
// savedToken is set in startChat, when it is started before this method is called
|
||||
if m.savedToken != nil {
|
||||
registerToken(token: deviceToken)
|
||||
}
|
||||
@@ -61,12 +36,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification")
|
||||
print("*** userInfo", userInfo)
|
||||
let m = ChatModel.shared
|
||||
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
||||
m.notificationMode != .off {
|
||||
if let verification = ntfData["verification"] as? String,
|
||||
let nonce = ntfData["nonce"] as? String {
|
||||
if let token = m.deviceToken {
|
||||
if let token = ChatModel.shared.deviceToken {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
|
||||
Task {
|
||||
do {
|
||||
@@ -86,7 +62,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo {
|
||||
if appStateGroupDefault.get().inactive {
|
||||
receiveMessages(completionHandler)
|
||||
} else {
|
||||
completionHandler(.noData)
|
||||
@@ -100,7 +76,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
logger.debug("DEBUGGING: AppDelegate: applicationWillTerminate")
|
||||
logger.debug("AppDelegate: applicationWillTerminate")
|
||||
ChatModel.shared.filesToDelete.forEach {
|
||||
removeFile($0)
|
||||
}
|
||||
@@ -126,10 +102,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
BGManager.shared.receiveMessages(complete)
|
||||
}
|
||||
|
||||
static func keepScreenOn(_ on: Bool) {
|
||||
UIApplication.shared.isIdleTimerDisabled = on
|
||||
}
|
||||
}
|
||||
|
||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||
|
||||
@@ -14,14 +14,11 @@ struct ContentView: View {
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var contentAccessAuthenticationExtended: Bool
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var automaticAuthenticationAttempted = false
|
||||
@State private var canConnectViewCall = false
|
||||
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
||||
|
||||
@Binding var doAuthenticate: Bool
|
||||
@Binding var userAuthorized: Bool?
|
||||
@Binding var canConnectCall: Bool
|
||||
@Binding var lastSuccessfulUnlock: TimeInterval?
|
||||
@Binding var showInitializationView: Bool
|
||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@@ -31,44 +28,17 @@ struct ContentView: View {
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .planAndConnectSheet(sheet): return sheet.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var accessAuthenticated: Bool {
|
||||
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 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()
|
||||
} else {
|
||||
lockButton()
|
||||
}
|
||||
contentView()
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
callView(call)
|
||||
}
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
.onDisappear {
|
||||
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
||||
waitingForOrPassedAuth = accessAuthenticated
|
||||
}
|
||||
} else if showSetPasscode {
|
||||
SetAppPasscodeView {
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
prefPerformLA = true
|
||||
showSetPasscode = false
|
||||
privacyLocalAuthModeDefault.set(.passcode)
|
||||
@@ -78,10 +48,15 @@ struct ContentView: View {
|
||||
showSetPasscode = false
|
||||
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! }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(showSettings: $showSettings)
|
||||
@@ -90,44 +65,14 @@ struct ContentView: View {
|
||||
Button("System authentication") { initialEnableLA() }
|
||||
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 {
|
||||
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)
|
||||
} else if !chatModel.v3DBMigration.startChat {
|
||||
MigrateToAppGroupView()
|
||||
@@ -135,11 +80,6 @@ struct ContentView: View {
|
||||
if case .onboardingComplete = step,
|
||||
chatModel.currentUser != nil {
|
||||
mainView()
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
@@ -150,11 +90,11 @@ struct ContentView: View {
|
||||
if CallController.useCallKit() {
|
||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||
.onDisappear {
|
||||
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
|
||||
if userAuthorized == false && doAuthenticate { runAuthenticate() }
|
||||
}
|
||||
} else {
|
||||
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
|
||||
if prefPerformLA && !accessAuthenticated {
|
||||
ActiveCallView(call: call, canConnectCall: $canConnectCall)
|
||||
if prefPerformLA && userAuthorized != true {
|
||||
Rectangle()
|
||||
.fill(colorScheme == .dark ? .black : .white)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -164,29 +104,24 @@ struct ContentView: 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 {
|
||||
VStack {
|
||||
ProgressView().scaleEffect(2)
|
||||
Text("Opening app…")
|
||||
Text("Opening database…")
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(.background)
|
||||
)
|
||||
}
|
||||
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
requestNtfAuthorization()
|
||||
if !prefPerformLA { requestNtfAuthorization() }
|
||||
// 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
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
@@ -197,15 +132,10 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
prefShowLANotice = true
|
||||
connectViaUrl()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
}
|
||||
IncomingCallView()
|
||||
}
|
||||
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
|
||||
@@ -236,37 +166,45 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
} else {
|
||||
return false
|
||||
private func initAuthenticate() {
|
||||
logger.debug("initAuthenticate")
|
||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
||||
userAuthorized = false
|
||||
} else if doAuthenticate {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticateContentViewAccess() {
|
||||
logger.debug("DEBUGGING: authenticateContentViewAccess")
|
||||
dismissAllSheets(animated: false) {
|
||||
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
|
||||
chatModel.chatId = nil
|
||||
private func runAuthenticate() {
|
||||
if !prefPerformLA {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
dismissAllSheets(animated: false) {
|
||||
chatModel.chatId = nil
|
||||
justAuthenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
canConnectViewCall = true
|
||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||
case .failed:
|
||||
chatModel.contentViewAccessAuthenticated = false
|
||||
if privacyLocalAuthModeDefault.get() == .passcode {
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
}
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
canConnectViewCall = true
|
||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||
private func justAuthenticate() {
|
||||
userAuthorized = false
|
||||
let laMode = privacyLocalAuthModeDefault.get()
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
userAuthorized = true
|
||||
canConnectCall = true
|
||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||
case .failed:
|
||||
if laMode == .passcode {
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
}
|
||||
case .unavailable:
|
||||
userAuthorized = true
|
||||
prefPerformLA = false
|
||||
canConnectCall = true
|
||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +235,6 @@ struct ContentView: View {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
prefPerformLA = true
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
case .failed:
|
||||
@@ -322,32 +259,36 @@ struct ContentView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaUrl() {
|
||||
dismissAllSheets() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
var path = url.path
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: showPlanAndConnectAlert,
|
||||
showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
|
||||
dismiss: false,
|
||||
incognito: nil
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
|
||||
}
|
||||
}
|
||||
}
|
||||
func connectViaUrl() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
AlertManager.shared.showAlert(connectViaUrlAlert(url))
|
||||
}
|
||||
}
|
||||
|
||||
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
|
||||
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
|
||||
func connectViaUrlAlert(_ url: URL) -> Alert {
|
||||
var path = url.path
|
||||
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let action: ConnReqType = path == "contact" ? .contact : .invitation
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
let title: LocalizedStringKey
|
||||
if case .contact = action { title = "Connect via contact link?" }
|
||||
else { title = "Connect via one-time link?" }
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
message: Text("Your profile will be sent to the contact that you received this link from"),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
connectViaLink(link)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ class AudioRecorder {
|
||||
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||
|
||||
await MainActor.run {
|
||||
AppDelegate.keepScreenOn(true)
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
guard let time = self.audioRecorder?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
@@ -58,10 +57,6 @@ class AudioRecorder {
|
||||
}
|
||||
return nil
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
AppDelegate.keepScreenOn(false)
|
||||
}
|
||||
try? av.setCategory(AVAudioSession.Category.soloAmbient)
|
||||
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
|
||||
return .error(error.localizedDescription)
|
||||
}
|
||||
@@ -76,8 +71,6 @@ class AudioRecorder {
|
||||
timer.invalidate()
|
||||
}
|
||||
recordingTimer = nil
|
||||
AppDelegate.keepScreenOn(false)
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient)
|
||||
}
|
||||
|
||||
private func checkPermission() async -> Bool {
|
||||
@@ -110,15 +103,9 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
self.onFinishPlayback = onFinishPlayback
|
||||
}
|
||||
|
||||
func start(fileSource: CryptoFile, at: TimeInterval?) {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
if let cfArgs = fileSource.cryptoArgs {
|
||||
if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) {
|
||||
audioPlayer = try? AVAudioPlayer(data: data)
|
||||
}
|
||||
} else {
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
}
|
||||
func start(fileName: String, at: TimeInterval?) {
|
||||
let url = getAppFilePath(fileName)
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
if let at = at {
|
||||
@@ -128,19 +115,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
if self.audioPlayer?.isPlaying ?? false {
|
||||
AppDelegate.keepScreenOn(true)
|
||||
guard let time = self.audioPlayer?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
AudioPlayer.changeAudioSession(true)
|
||||
} else {
|
||||
AudioPlayer.changeAudioSession(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
AppDelegate.keepScreenOn(false)
|
||||
}
|
||||
|
||||
func play() {
|
||||
@@ -161,8 +143,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
func stop() {
|
||||
if let player = audioPlayer {
|
||||
player.stop()
|
||||
AppDelegate.keepScreenOn(false)
|
||||
AudioPlayer.changeAudioSession(false)
|
||||
}
|
||||
audioPlayer = nil
|
||||
if let timer = playbackTimer {
|
||||
@@ -171,24 +151,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
playbackTimer = nil
|
||||
}
|
||||
|
||||
static func changeAudioSession(_ playback: Bool) {
|
||||
// When there is a audio recording, setting any other category will disable sound
|
||||
if AVAudioSession.sharedInstance().category == .playAndRecord {
|
||||
return
|
||||
}
|
||||
if playback {
|
||||
if AVAudioSession.sharedInstance().category != .playback {
|
||||
logger.log("AudioSession: playback")
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
|
||||
}
|
||||
} else {
|
||||
if AVAudioSession.sharedInstance().category != .soloAmbient {
|
||||
logger.log("AudioSession: soloAmbient")
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
stop()
|
||||
self.onFinishPlayback?()
|
||||
|
||||
@@ -15,13 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive"
|
||||
// TCP timeout + 2 sec
|
||||
private let waitForMessages: TimeInterval = 6
|
||||
|
||||
// This is the smallest interval between refreshes, and also target interval in "off" mode
|
||||
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 bgRefreshInterval: TimeInterval = 450
|
||||
|
||||
private let maxTimerCount = 9
|
||||
|
||||
@@ -39,14 +33,10 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(interval: TimeInterval? = nil) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
return
|
||||
}
|
||||
func schedule() {
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
@@ -54,34 +44,16 @@ 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) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
let shouldRun_ = lastRanLongAgo
|
||||
if allowBackgroundRefresh() && shouldRun_ {
|
||||
schedule()
|
||||
schedule()
|
||||
if appStateGroupDefault.get().inactive {
|
||||
let completeRefresh = completionHandler {
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
task.expirationHandler = { completeRefresh("expirationHandler") }
|
||||
receiveMessages(completeRefresh)
|
||||
} else {
|
||||
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
|
||||
logger.debug("BGManager.completionHandler: already active, not started")
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
@@ -110,22 +82,20 @@ class BGManager {
|
||||
}
|
||||
self.completed = false
|
||||
DispatchQueue.main.async {
|
||||
chatLastBackgroundRunGroupDefault.set(Date.now)
|
||||
let m = ChatModel.shared
|
||||
if (!m.chatInitialized) {
|
||||
setAppState(.bgRefresh)
|
||||
do {
|
||||
try initializeChat(start: true)
|
||||
} catch let error {
|
||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
activateChat(appState: .bgRefresh)
|
||||
if m.currentUser == nil {
|
||||
completeReceiving("no current user")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.receiveMessages: starting chat")
|
||||
activateChat(appState: .bgRefresh)
|
||||
let cr = ChatReceiver()
|
||||
self.chatReceiver = cr
|
||||
cr.start()
|
||||
|
||||
@@ -11,41 +11,8 @@ import Combine
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
actor TerminalItems {
|
||||
private var terminalItems: [TerminalItem] = []
|
||||
|
||||
static let shared = TerminalItems()
|
||||
|
||||
func items() -> [TerminalItem] {
|
||||
terminalItems
|
||||
}
|
||||
|
||||
func add(_ item: TerminalItem) async {
|
||||
addTermItem(&terminalItems, item)
|
||||
let m = ChatModel.shared
|
||||
if m.showingTerminal {
|
||||
await MainActor.run {
|
||||
addTermItem(&m.terminalItems, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
|
||||
await add(.cmd(start, cmd))
|
||||
await add(.resp(.now, resp))
|
||||
}
|
||||
}
|
||||
|
||||
private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
if items.count >= 200 {
|
||||
items.removeFirst()
|
||||
}
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||
@Published var currentUser: User?
|
||||
@Published var users: [UserInfo] = []
|
||||
@@ -54,23 +21,17 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatDbChanged = false
|
||||
@Published var chatDbEncrypted: Bool?
|
||||
@Published var chatDbStatus: DBMigrationResult?
|
||||
@Published var ctrlInitInProgress: Bool = false
|
||||
// local authentication
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published var chats: [Chat] = []
|
||||
@Published var deletedChats: Set<String> = []
|
||||
// map of connections network statuses, key is agent connection id
|
||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
@Published var groupMembers: [GroupMember] = []
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: UserContactLink?
|
||||
@Published var chatItemTTL: ChatItemTTL = .none
|
||||
@@ -80,26 +41,22 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenRegistered = false
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
|
||||
@Published var incognito: Bool = incognitoGroupDefault.get()
|
||||
// pending notification actions
|
||||
@Published var ntfContactRequest: NTFContactRequest?
|
||||
@Published var ntfContactRequest: ChatId?
|
||||
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
|
||||
// current WebRTC call
|
||||
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||
@Published var activeCall: Call?
|
||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||
@Published var callCommand: WCallCommand?
|
||||
@Published var showCallView = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
@Published var showingInvitation: ShowingInvitation?
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
@@ -109,16 +66,6 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
let ntfEnableLocal = true
|
||||
|
||||
var ntfEnablePeriodic: Bool {
|
||||
notificationMode != .off
|
||||
}
|
||||
|
||||
var activeRemoteCtrl: Bool {
|
||||
remoteCtrlSession?.active ?? false
|
||||
}
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@@ -139,7 +86,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func removeUser(_ user: User) {
|
||||
if let i = getUserIndex(user) {
|
||||
if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId {
|
||||
users.remove(at: i)
|
||||
}
|
||||
}
|
||||
@@ -162,20 +109,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupChat(_ groupId: Int64) -> Chat? {
|
||||
chats.first { chat in
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
return groupInfo.groupId == groupId
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
||||
}
|
||||
|
||||
private func getChatIndex(_ id: String) -> Int? {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
@@ -189,7 +122,6 @@ final class ChatModel: ObservableObject {
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatInfo = cInfo
|
||||
chats[i].created = Date.now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,14 +133,6 @@ final class ChatModel: ObservableObject {
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
|
||||
}
|
||||
|
||||
func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) {
|
||||
var updatedConn = contact.activeConn
|
||||
updatedConn?.connectionStats = connectionStats
|
||||
var updatedContact = contact
|
||||
updatedContact.activeConn = updatedConn
|
||||
updateContact(updatedContact)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
updateChat(.group(groupInfo: groupInfo))
|
||||
}
|
||||
@@ -270,20 +194,7 @@ final class ChatModel: ObservableObject {
|
||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update previews
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = switch cInfo {
|
||||
case .group:
|
||||
if let currentPreviewItem = chats[i].chatItems.first {
|
||||
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
|
||||
[cItem]
|
||||
} else {
|
||||
[currentPreviewItem]
|
||||
}
|
||||
} else {
|
||||
[cItem]
|
||||
}
|
||||
default:
|
||||
[cItem]
|
||||
}
|
||||
chats[i].chatItems = [cItem]
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
increaseUnreadCounter(user: currentUser!)
|
||||
@@ -334,11 +245,7 @@ final class ChatModel: ObservableObject {
|
||||
return false
|
||||
} else {
|
||||
withAnimation(itemAnimation()) {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -351,22 +258,26 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
} else if let status = status {
|
||||
chatItemStatuses.updateValue(status, forKey: cItem.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
let ci = reversedChatItems[i]
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
}
|
||||
|
||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
}
|
||||
|
||||
@@ -502,7 +413,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
reversedChatItems = []
|
||||
}
|
||||
}
|
||||
@@ -534,18 +444,18 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func increaseUnreadCounter(user: any UserLike) {
|
||||
func increaseUnreadCounter(user: User) {
|
||||
changeUnreadCounter(user: user, by: 1)
|
||||
NtfManager.shared.incNtfBadgeCount()
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||
func decreaseUnreadCounter(user: User, by: Int = 1) {
|
||||
changeUnreadCounter(user: user, by: -by)
|
||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||
private func changeUnreadCounter(user: User, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.id == user.id }) {
|
||||
users[i].unreadCount += by
|
||||
}
|
||||
}
|
||||
@@ -555,62 +465,14 @@ final class ChatModel: ObservableObject {
|
||||
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
|
||||
}
|
||||
|
||||
// this function analyses "connected" events and assumes that each member will be there only once
|
||||
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
|
||||
var count = 0
|
||||
var ns: [String] = []
|
||||
if let ciCategory = chatItem.mergeCategory,
|
||||
var i = getChatItemIndex(chatItem) {
|
||||
while i < reversedChatItems.count {
|
||||
let ci = reversedChatItems[i]
|
||||
if ci.mergeCategory != ciCategory { break }
|
||||
if let m = ci.memberConnected {
|
||||
ns.append(m.displayName)
|
||||
}
|
||||
count += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return (count, ns)
|
||||
}
|
||||
|
||||
// returns the index of the passed item and the next item (it has smaller index)
|
||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
(nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// returns the index of the first item in the same merged group (the first hidden item)
|
||||
// and the previous visible item with another merge category
|
||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||
guard var i = ciIndex else { return (nil, nil) }
|
||||
let fst = reversedChatItems.count - 1
|
||||
while i < fst {
|
||||
i = i + 1
|
||||
let ci = reversedChatItems[i]
|
||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||
return (i - 1, ci)
|
||||
}
|
||||
}
|
||||
return (i, nil)
|
||||
}
|
||||
|
||||
// returns the previous member in the same merge group and the count of members in this group
|
||||
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
for i in range {
|
||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
}
|
||||
return (prevMember, memberIds.count)
|
||||
}
|
||||
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
popChat_(i)
|
||||
@@ -623,16 +485,14 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func dismissConnReqView(_ id: String) {
|
||||
if id == showingInvitation?.connId {
|
||||
markShowingInvitationUsed()
|
||||
if let connReqInv = connReqInv,
|
||||
let c = getChat(id),
|
||||
case let .contactConnection(contactConnection) = c.chatInfo,
|
||||
connReqInv == contactConnection.connReqInv {
|
||||
dismissAllSheets()
|
||||
}
|
||||
}
|
||||
|
||||
func markShowingInvitationUsed() {
|
||||
showingInvitation?.connChatUsed = true
|
||||
}
|
||||
|
||||
func removeChat(_ id: String) {
|
||||
withAnimation {
|
||||
chats.removeAll(where: { $0.id == id })
|
||||
@@ -647,14 +507,13 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i].wrapped = member
|
||||
self.groupMembers[i].created = Date.now
|
||||
self.groupMembers[i] = member
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { groupMembers.append(GMember(member)) }
|
||||
withAnimation { groupMembers.append(member) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -662,15 +521,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
|
||||
if var conn = member.activeConn {
|
||||
conn.connectionStats = connectionStats
|
||||
var updatedMember = member
|
||||
updatedMember.activeConn = conn
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
@@ -695,28 +545,19 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||
if let conn = contact.activeConn {
|
||||
networkStatuses[conn.agentConnId] = status
|
||||
}
|
||||
networkStatuses[contact.activeConn.agentConnId] = status
|
||||
}
|
||||
|
||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||
if let conn = contact.activeConn {
|
||||
networkStatuses[conn.agentConnId] ?? .unknown
|
||||
} else {
|
||||
.unknown
|
||||
}
|
||||
networkStatuses[contact.activeConn.agentConnId] ?? .unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
var connId: String
|
||||
var connChatUsed: Bool
|
||||
}
|
||||
|
||||
struct NTFContactRequest {
|
||||
var incognito: Bool
|
||||
var chatId: String
|
||||
func addTerminalItem(_ item: TerminalItem) {
|
||||
if terminalItems.count >= 500 {
|
||||
terminalItems.remove(at: 0)
|
||||
}
|
||||
terminalItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts {
|
||||
@@ -756,8 +597,6 @@ final class Chat: ObservableObject, Identifiable {
|
||||
case let .group(groupInfo):
|
||||
let m = groupInfo.membership
|
||||
return m.memberActive && m.memberRole >= .member
|
||||
case .local:
|
||||
return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -778,53 +617,40 @@ final class Chat: ObservableObject, Identifiable {
|
||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
}
|
||||
|
||||
final class GMember: ObservableObject, Identifiable {
|
||||
@Published var wrapped: GroupMember
|
||||
var created = Date.now
|
||||
enum NetworkStatus: Decodable, Equatable {
|
||||
case unknown
|
||||
case connected
|
||||
case disconnected
|
||||
case error(String)
|
||||
|
||||
init(_ member: GroupMember) {
|
||||
self.wrapped = member
|
||||
var statusString: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "connected"
|
||||
case .error: return "error"
|
||||
default: return "connecting"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id: String { wrapped.id }
|
||||
var groupId: Int64 { wrapped.groupId }
|
||||
var groupMemberId: Int64 { wrapped.groupMemberId }
|
||||
var displayName: String { wrapped.displayName }
|
||||
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
|
||||
static let sampleData = GMember(GroupMember.sampleData)
|
||||
}
|
||||
|
||||
struct RemoteCtrlSession {
|
||||
var ctrlAppInfo: CtrlAppInfo?
|
||||
var appVersion: String
|
||||
var sessionState: UIRemoteCtrlSessionState
|
||||
|
||||
func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession {
|
||||
RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state)
|
||||
var statusExplanation: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "You are connected to the server used to receive messages from this contact."
|
||||
case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
|
||||
default: return "Trying to connect to the server used to receive messages from this contact."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var active: Bool {
|
||||
if case .connected = sessionState { true } else { false }
|
||||
}
|
||||
|
||||
var discovery: Bool {
|
||||
if case .searching = sessionState { true } else { false }
|
||||
}
|
||||
|
||||
var sessionCode: String? {
|
||||
switch sessionState {
|
||||
case let .pendingConfirmation(_, sessionCode): sessionCode
|
||||
case let .connected(_, sessionCode): sessionCode
|
||||
default: nil
|
||||
var imageName: String {
|
||||
get {
|
||||
switch self {
|
||||
case .unknown: return "circle.dotted"
|
||||
case .connected: return "circle.fill"
|
||||
case .disconnected: return "ellipsis.circle.fill"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UIRemoteCtrlSessionState {
|
||||
case starting
|
||||
case searching
|
||||
case found(remoteCtrl: RemoteCtrlInfo, compatible: Bool)
|
||||
case connecting(remoteCtrl_: RemoteCtrlInfo?)
|
||||
case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
|
||||
case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String)
|
||||
}
|
||||
|
||||
@@ -11,43 +11,42 @@ import SimpleXChat
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
||||
if let file = file, file.loaded {
|
||||
return file.fileSource
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
return getAppFilePath(fileName).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedFileName(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.loaded,
|
||||
let fileName = file.filePath {
|
||||
return fileName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
let data = try getFileData(filePath, fileSource.cryptoArgs)
|
||||
let data = try Data(contentsOf: filePath)
|
||||
let img = UIImage(data: data)
|
||||
do {
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return nil
|
||||
return UIImage(contentsOfFile: loadedFilePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
||||
if let cfArgs = cfArgs {
|
||||
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
return try Data(contentsOf: path)
|
||||
}
|
||||
}
|
||||
|
||||
func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if loadedFilePath != nil, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
if FileManager.default.fileExists(atPath: filePath.path) {
|
||||
return filePath
|
||||
}
|
||||
@@ -55,18 +54,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
return saveFile(imageData, fileName)
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
let hasAlpha = imageHasAlpha(uiImage)
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
|
||||
let fileName = generateNewFileName("IMG", ext)
|
||||
return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,20 +157,13 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
let savedFile: CryptoFile?
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
do {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
let toPath = getAppFilePath(fileName).path
|
||||
if encrypted {
|
||||
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath)
|
||||
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
try FileManager.default.copyItem(atPath: url.path, toPath: toPath)
|
||||
savedFile = CryptoFile.plain(fileName)
|
||||
}
|
||||
savedFile = saveFile(fileData, fileName)
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
@@ -184,39 +176,32 @@ func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
do {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
let savedFile: CryptoFile?
|
||||
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)
|
||||
}
|
||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return savedFile
|
||||
savedFile = fileName
|
||||
} catch {
|
||||
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
||||
return nil
|
||||
logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
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 {
|
||||
let ns = fileName as NSString
|
||||
let name = ns.deletingPathExtension
|
||||
let ext = ns.pathExtension
|
||||
let suffix = (n == 0) ? "" : "_\(n)"
|
||||
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)
|
||||
}
|
||||
@@ -303,4 +288,4 @@ extension UIImage {
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import UIKit
|
||||
import SimpleXChat
|
||||
|
||||
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
|
||||
let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
|
||||
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
|
||||
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
|
||||
|
||||
@@ -42,13 +41,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
userId != chatModel.currentUser?.userId {
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
||||
let chatId = content.userInfo["chatId"] as? String {
|
||||
let incognito = action == ntfActionAcceptContactIncognito
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} else {
|
||||
chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
|
||||
chatModel.ntfContactRequest = chatId
|
||||
}
|
||||
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
|
||||
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
|
||||
@@ -136,17 +134,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactRequest,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: ntfActionAcceptContact,
|
||||
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
), UNNotificationAction(
|
||||
identifier: ntfActionAcceptContactIncognito,
|
||||
title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
)
|
||||
],
|
||||
actions: [UNNotificationAction(
|
||||
identifier: ntfActionAcceptContact,
|
||||
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
)],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification")
|
||||
),
|
||||
@@ -211,17 +203,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest))
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
|
||||
func notifyContactConnected(_ user: User, _ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(createContactConnectedNtf(user, contact))
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled {
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,63 +9,51 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
|
||||
|
||||
let appSuspendTimeout: Int = 15 // seconds
|
||||
|
||||
let bgSuspendTimeout: Int = 5 // seconds
|
||||
|
||||
let terminationTimeout: Int = 3 // seconds
|
||||
|
||||
let activationDelay: TimeInterval = 1.5
|
||||
|
||||
let nseSuspendTimeout: TimeInterval = 5
|
||||
|
||||
private func _suspendChat(timeout: Int) {
|
||||
// this is a redundant check to prevent logical errors, like the one fixed in this PR
|
||||
let state = AppChatState.shared.value
|
||||
if !state.canSuspend {
|
||||
logger.error("_suspendChat called, current state: \(state.rawValue)")
|
||||
} else if ChatModel.ok {
|
||||
AppChatState.shared.set(.suspending)
|
||||
if ChatModel.ok {
|
||||
appStateGroupDefault.set(.suspending)
|
||||
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
||||
let endTask = beginBGTask(chatSuspended)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
|
||||
} else {
|
||||
AppChatState.shared.set(.suspended)
|
||||
appStateGroupDefault.set(.suspended)
|
||||
}
|
||||
}
|
||||
|
||||
func suspendChat() {
|
||||
suspendLockQueue.sync {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
if appStateGroupDefault.get() != .stopped {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func suspendBgRefresh() {
|
||||
suspendLockQueue.sync {
|
||||
if case .bgRefresh = AppChatState.shared.value {
|
||||
if case .bgRefresh = appStateGroupDefault.get() {
|
||||
_suspendChat(timeout: bgSuspendTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func terminateChat() {
|
||||
logger.debug("terminateChat")
|
||||
suspendLockQueue.sync {
|
||||
switch AppChatState.shared.value {
|
||||
switch appStateGroupDefault.get() {
|
||||
case .suspending:
|
||||
// suspend instantly if already suspending
|
||||
_chatSuspended()
|
||||
// when apiSuspendChat is called with timeout 0, it won't send any events on suspension
|
||||
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
|
||||
chatCloseStore()
|
||||
case .suspended:
|
||||
chatCloseStore()
|
||||
case .stopped:
|
||||
chatCloseStore()
|
||||
case .stopped: ()
|
||||
default:
|
||||
// the store will be closed in _chatSuspended when event is received
|
||||
_suspendChat(timeout: terminationTimeout)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +61,7 @@ func terminateChat() {
|
||||
|
||||
func chatSuspended() {
|
||||
suspendLockQueue.sync {
|
||||
if case .suspending = AppChatState.shared.value {
|
||||
if case .suspending = appStateGroupDefault.get() {
|
||||
_chatSuspended()
|
||||
}
|
||||
}
|
||||
@@ -81,111 +69,36 @@ func chatSuspended() {
|
||||
|
||||
private func _chatSuspended() {
|
||||
logger.debug("_chatSuspended")
|
||||
AppChatState.shared.set(.suspended)
|
||||
appStateGroupDefault.set(.suspended)
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.stop()
|
||||
}
|
||||
chatCloseStore()
|
||||
}
|
||||
|
||||
func setAppState(_ appState: AppState) {
|
||||
suspendLockQueue.sync {
|
||||
AppChatState.shared.set(appState)
|
||||
}
|
||||
}
|
||||
|
||||
func activateChat(appState: AppState = .active) {
|
||||
logger.debug("DEBUGGING: activateChat")
|
||||
suspendLockQueue.sync {
|
||||
AppChatState.shared.set(appState)
|
||||
appStateGroupDefault.set(appState)
|
||||
if ChatModel.ok { apiActivateChat() }
|
||||
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
||||
}
|
||||
}
|
||||
|
||||
func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
let m = ChatModel.shared
|
||||
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 {
|
||||
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 {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: start ? "Error starting chat" : "Error opening chat",
|
||||
message: "Please contact developers.\nError: \(responseError(error))"
|
||||
)
|
||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startChatForCall() {
|
||||
logger.debug("DEBUGGING: startChatForCall")
|
||||
func startChatAndActivate() {
|
||||
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")
|
||||
if .active != appStateGroupDefault.get() {
|
||||
activateChat()
|
||||
logger.debug("DEBUGGING: startChatForCall: after activateChat")
|
||||
}
|
||||
}
|
||||
|
||||
func startChatAndActivate(_ completion: @escaping () -> Void) {
|
||||
logger.debug("DEBUGGING: startChatAndActivate")
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.start()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
||||
}
|
||||
if case .active = AppChatState.shared.value {
|
||||
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")
|
||||
activateChat()
|
||||
completion()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,17 @@ struct SimpleXApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
|
||||
@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() {
|
||||
DispatchQueue.global(qos: .background).sync {
|
||||
haskell_init()
|
||||
// hs_init(0, nil)
|
||||
}
|
||||
hs_init(0, nil)
|
||||
UserDefaults.standard.register(defaults: appDefaults)
|
||||
setGroupDefaults()
|
||||
registerGroupDefaults()
|
||||
@@ -34,55 +36,53 @@ struct SimpleXApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
|
||||
// so that it's computed by the time view renders, and not on event after rendering
|
||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||
return WindowGroup {
|
||||
ContentView(
|
||||
doAuthenticate: $doAuthenticate,
|
||||
userAuthorized: $userAuthorized,
|
||||
canConnectCall: $canConnectCall,
|
||||
lastSuccessfulUnlock: $lastSuccessfulUnlock,
|
||||
showInitializationView: $showInitializationView
|
||||
)
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
.onAppear() {
|
||||
if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
showInitializationView = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
switch (phase) {
|
||||
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 {
|
||||
CallController.shared.shouldSuspendChat = true
|
||||
} else {
|
||||
suspendChat()
|
||||
BGManager.shared.schedule()
|
||||
}
|
||||
if userAuthorized == true {
|
||||
enteredBackground = ProcessInfo.processInfo.systemUptime
|
||||
}
|
||||
doAuthenticate = false
|
||||
canConnectCall = false
|
||||
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
||||
case .active:
|
||||
CallController.shared.shouldSuspendChat = false
|
||||
let appState = AppChatState.shared.value
|
||||
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
let appState = appStateGroupDefault.get()
|
||||
startChatAndActivate()
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
doAuthenticate = authenticationExpired()
|
||||
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -100,12 +100,12 @@ struct SimpleXApp: App {
|
||||
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
|
||||
dbContainerGroupDefault.set(.documents)
|
||||
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 {
|
||||
dbContainerGroupDefault.set(.group)
|
||||
setMigrationState(.ready)
|
||||
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db")
|
||||
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present")
|
||||
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
|
||||
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,14 +115,22 @@ struct SimpleXApp: App {
|
||||
}
|
||||
|
||||
private func authenticationExpired() -> Bool {
|
||||
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
|
||||
if let enteredBackground = enteredBackground {
|
||||
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChats() {
|
||||
do {
|
||||
let chats = try apiGetChats()
|
||||
@@ -131,10 +139,10 @@ struct SimpleXApp: App {
|
||||
let chat = chatModel.getChat(id) {
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
if let chatId = chatModel.ntfContactRequest {
|
||||
chatModel.ntfContactRequest = nil
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
|
||||
@@ -38,21 +38,19 @@ struct ActiveCallView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||
AppDelegate.keepScreenOn(true)
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
|
||||
createWebRTCClient()
|
||||
dismissAllSheets()
|
||||
}
|
||||
.onChange(of: canConnectCall) { _ in
|
||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
|
||||
createWebRTCClient()
|
||||
}
|
||||
.onDisappear {
|
||||
logger.debug("ActiveCallView: disappear")
|
||||
Task { await m.callCommand.setClient(nil) }
|
||||
AppDelegate.keepScreenOn(false)
|
||||
client?.endCall()
|
||||
}
|
||||
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
|
||||
.background(.black)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -60,8 +58,19 @@ struct ActiveCallView: View {
|
||||
private func createWebRTCClient() {
|
||||
if client == nil && canConnectCall {
|
||||
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 {
|
||||
await m.callCommand.setClient(client)
|
||||
await client.sendCallCommand(command: cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,10 +166,8 @@ struct ActiveCallView: View {
|
||||
}
|
||||
case let .error(message):
|
||||
logger.debug("ActiveCallView: command error: \(message)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
|
||||
case let .invalid(type):
|
||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +253,7 @@ struct ActiveCallOverlay: View {
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
try audioSession.setActive(true)
|
||||
logger.debug("audioSession activated")
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("failed activating audio session")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +121,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
try audioSession.setActive(false)
|
||||
logger.debug("audioSession deactivated")
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("failed deactivating audio session")
|
||||
}
|
||||
suspendOnEndCall()
|
||||
@@ -130,7 +132,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
// The delay allows to accept the second call before suspending a chat
|
||||
// see `.onChange(of: scenePhase)` in SimpleXApp
|
||||
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 {
|
||||
self?.shouldSuspendChat = false
|
||||
suspendChat()
|
||||
@@ -142,46 +144,33 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
||||
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) {
|
||||
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 {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
if AppChatState.shared.value == .stopped {
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
logger.debug("CallController: initializing chat")
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
initChatAndMigrate(refreshInvitations: false)
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
startChatAndActivate()
|
||||
shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
_ = try? justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
}
|
||||
@@ -189,10 +178,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
completion()
|
||||
}
|
||||
} else {
|
||||
self.reportExpiredCall(update: update, completion)
|
||||
reportExpiredCall(update: update, completion)
|
||||
}
|
||||
} else {
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
reportExpiredCall(payload: payload, completion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +212,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
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 invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
@@ -363,7 +352,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
|
||||
controller.request(CXTransaction(action: action)) { error in
|
||||
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 {
|
||||
logger.debug("CallController.requestTransaction requested transaction successfully")
|
||||
onSuccess()
|
||||
|
||||
@@ -22,7 +22,7 @@ class CallManager {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
m.callCommand = .capabilities(media: call.localMedia)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -57,21 +57,19 @@ class CallManager {
|
||||
m.activeCall = call
|
||||
m.showCallView = true
|
||||
|
||||
Task {
|
||||
await m.callCommand.processCommand(.start(
|
||||
m.callCommand = .start(
|
||||
media: invitation.callType.media,
|
||||
aesKey: invitation.sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
m.callCommand = .media(media: media, enable: enable)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -96,13 +94,11 @@ class CallManager {
|
||||
completed()
|
||||
} else {
|
||||
logger.debug("CallManager.endCall: ending call...")
|
||||
m.callCommand = .end
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
Task {
|
||||
await m.callCommand.processCommand(.end)
|
||||
await MainActor.run {
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
}
|
||||
do {
|
||||
try await apiEndCall(call.contact)
|
||||
} catch {
|
||||
|
||||
@@ -335,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 {
|
||||
var connectionState: String
|
||||
var iceConnectionState: String
|
||||
@@ -402,12 +358,26 @@ struct ConnectionInfo: Codable, Equatable {
|
||||
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
|
||||
struct RTCIceCandidate: Codable, Equatable {
|
||||
var candidateType: RTCIceCandidateType?
|
||||
var `protocol`: String?
|
||||
var relayProtocol: String?
|
||||
var sdpMid: String?
|
||||
var sdpMLineIndex: Int?
|
||||
var candidate: String
|
||||
|
||||
@@ -18,11 +18,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}()
|
||||
private static let ivTagBytes: Int = 28
|
||||
private static let enableEncryption: Bool = true
|
||||
private var chat_ctrl = getChatCtrl()
|
||||
|
||||
struct Call {
|
||||
var connection: RTCPeerConnection
|
||||
var iceCandidates: IceCandidates
|
||||
var iceCandidates: [RTCIceCandidate]
|
||||
var localMedia: CallMediaType
|
||||
var localCamera: RTCVideoCapturer?
|
||||
var localVideoSource: RTCVideoSource?
|
||||
@@ -34,24 +33,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
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 audioQueue = DispatchQueue(label: "audio")
|
||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||
var activeCall: Binding<Call?>
|
||||
private var activeCall: Binding<Call?>
|
||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||
|
||||
@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"),
|
||||
]
|
||||
|
||||
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)
|
||||
connection.delegate = self
|
||||
createAudioSender(connection)
|
||||
@@ -102,7 +87,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
return Call(
|
||||
connection: connection,
|
||||
iceCandidates: IceCandidates(),
|
||||
iceCandidates: remoteIceCandidates,
|
||||
localMedia: mediaType,
|
||||
localCamera: localCamera,
|
||||
localVideoSource: localVideoSource,
|
||||
@@ -159,18 +144,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
logger.debug("starting incoming call - create webrtc session")
|
||||
if activeCall.wrappedValue != nil { endCall() }
|
||||
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
|
||||
let (offer, error) = await call.connection.offer()
|
||||
if let offer = offer {
|
||||
resp = .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
call.connection.offer { answer in
|
||||
Task {
|
||||
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
|
||||
if gotCandidates {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
} else {
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
||||
if activeCall.wrappedValue != nil {
|
||||
@@ -179,21 +172,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .error(message: "accept: encryption is not supported")
|
||||
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
activeCall.wrappedValue = call
|
||||
let pc = call.connection
|
||||
if let type = offer.type, let sdp = offer.sdp {
|
||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||
let (answer, error) = await pc.answer()
|
||||
if let answer = answer {
|
||||
pc.answer { answer in
|
||||
self.addIceCandidates(pc, remoteIceCandidates)
|
||||
resp = .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
// Task {
|
||||
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
|
||||
Task {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
} else {
|
||||
resp = .error(message: "accept: remote description is not set")
|
||||
@@ -236,7 +234,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .ok
|
||||
}
|
||||
case .end:
|
||||
// TODO possibly, endCall should be called before returning .ok
|
||||
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
|
||||
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) {
|
||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(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)
|
||||
let isKeyFrame = unencrypted[0] & 1 == 0
|
||||
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)
|
||||
} else {
|
||||
return nil
|
||||
@@ -417,13 +387,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
audioSessionToDefaults()
|
||||
}
|
||||
|
||||
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||
var t: UInt64 = 0
|
||||
repeat {
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
await action()
|
||||
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
||||
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
|
||||
let startedAt = DispatchTime.now()
|
||||
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
|
||||
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
|
||||
}
|
||||
return success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,33 +405,25 @@ extension WebRTC.RTCPeerConnection {
|
||||
optionalConstraints: nil)
|
||||
}
|
||||
|
||||
func offer() async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
guard let sdp = sdp else {
|
||||
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
|
||||
if let error = error {
|
||||
cont.resume(returning: (nil, error))
|
||||
} else {
|
||||
cont.resume(returning: (sdp, nil))
|
||||
}
|
||||
completion(sdp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,7 +479,6 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
default: enableSpeaker = false
|
||||
}
|
||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||
case .connected: sendConnectedEvent(connection)
|
||||
case .disconnected, .failed: endCall()
|
||||
default: do {}
|
||||
}
|
||||
@@ -531,9 +491,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
||||
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
||||
Task {
|
||||
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||
}
|
||||
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
|
||||
}
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
|
||||
@@ -548,9 +506,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
lastReceivedMs lastDataReceivedMs: Int32,
|
||||
changeReason reason: String) {
|
||||
// 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
|
||||
stats.statistics.values.forEach { stat in
|
||||
// logger.debug("Stat \(stat.debugDescription)")
|
||||
@@ -558,25 +517,24 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
let localId = stat.values["localCandidateId"] as? String,
|
||||
let remoteId = stat.values["remoteCandidateId"] as? String,
|
||||
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 {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .connected(connectionInfo: ConnectionInfo(
|
||||
localCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
protocol: localStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""
|
||||
localCandidate: local.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
localStats.values["protocol"] as? String,
|
||||
localStats.values["relayProtocol"] as? String
|
||||
),
|
||||
remoteCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
protocol: remoteStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""))),
|
||||
remoteCandidate: remote.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
remoteStats.values["protocol"] as? String,
|
||||
remoteStats.values["relayProtocol"] as? String
|
||||
))),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
@@ -676,10 +634,11 @@ extension RTCIceCandidate {
|
||||
}
|
||||
|
||||
extension WebRTC.RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
|
||||
RTCIceCandidate(
|
||||
candidateType: candidateType,
|
||||
protocol: `protocol`,
|
||||
relayProtocol: relayProtocol,
|
||||
sdpMid: sdpMid,
|
||||
sdpMLineIndex: Int(sdpMLineIndex),
|
||||
candidate: sdp
|
||||
|
||||
@@ -57,37 +57,6 @@ private func serverHost(_ s: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
enum SendReceipts: Identifiable, Hashable {
|
||||
case yes
|
||||
case no
|
||||
case userDefault(Bool)
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .yes: return "yes"
|
||||
case .no: return "no"
|
||||
case let .userDefault(on): return on ? "default (yes)" : "default (no)"
|
||||
}
|
||||
}
|
||||
|
||||
func bool() -> Bool? {
|
||||
switch self {
|
||||
case .yes: return true
|
||||
case .no: return false
|
||||
case .userDefault: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fromBool(_ enable: Bool?, userDefault def: Bool) -> SendReceipts {
|
||||
if let enable = enable {
|
||||
return enable ? .yes : .no
|
||||
}
|
||||
return .userDefault(def)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -99,26 +68,23 @@ struct ChatInfoView: View {
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
case deleteContactAlert
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleteContactAlert: return "deleteContactAlert"
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -142,34 +108,20 @@ struct ChatInfoView: View {
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section("Incognito") {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
infoRow("Your random profile", customUserProfile.chatViewName)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
@@ -180,32 +132,24 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,42 +168,16 @@ struct ChatInfoView: View {
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case .deleteContactAlert: return deleteContactAlert()
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
|
||||
.destructive(Text("Delete")) { deleteContact(notify: false) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { deleteContact() },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contactInfoHeader() -> some View {
|
||||
@@ -269,30 +187,20 @@ struct ChatInfoView: View {
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if contact.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
HStack {
|
||||
if contact.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
}
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -338,7 +246,7 @@ struct ChatInfoView: View {
|
||||
verify: { code in
|
||||
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
contact.activeConn?.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
DispatchQueue.main.async {
|
||||
chat.chatInfo = .direct(contact: contact)
|
||||
@@ -372,44 +280,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncContactConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
@@ -432,7 +302,7 @@ struct ChatInfoView: View {
|
||||
|
||||
private func deleteContactButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteContactActionSheet = true
|
||||
alert = .deleteContactAlert
|
||||
} label: {
|
||||
Label("Delete contact", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
@@ -448,23 +318,30 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContact(notify: Bool? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
private func deleteContactAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete contact?"),
|
||||
message: Text("Contact and all messages will be deleted - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
chatModel.chatId = nil
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error deleting contact")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error deleting contact")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func clearChatAlert() -> Alert {
|
||||
@@ -493,10 +370,6 @@ struct ChatInfoView: View {
|
||||
do {
|
||||
let stats = try apiSwitchContact(contactId: contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -512,9 +385,6 @@ struct ChatInfoView: View {
|
||||
do {
|
||||
let stats = try apiAbortSwitchContact(contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
@@ -524,25 +394,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||
@@ -563,15 +414,6 @@ func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Aler
|
||||
)
|
||||
}
|
||||
|
||||
func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Renegotiate encryption?"),
|
||||
message: Text("The encryption is working and the new encryption agreement is not required. It may result in connection errors!"),
|
||||
primaryButton: .destructive(Text("Renegotiate"), action: syncConnectionForce),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
struct ChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoView(
|
||||
|
||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct CICallItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var status: CICallStatus
|
||||
var duration: Int
|
||||
@@ -60,7 +60,7 @@ struct CICallItemView: View {
|
||||
|
||||
|
||||
@ViewBuilder private func acceptCallButton() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
if case let .direct(contact) = chatInfo {
|
||||
Button {
|
||||
if let invitation = m.callInvitations[contact.id] {
|
||||
CallController.shared.answerCall(invitation: invitation)
|
||||
|
||||
@@ -10,92 +10,20 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIChatFeatureView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var feature: Feature
|
||||
var icon: String? = nil
|
||||
var iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
if !revealed, let fs = mergedFeautures() {
|
||||
HStack {
|
||||
ForEach(fs, content: featureIconView)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 6)
|
||||
} else {
|
||||
fullFeatureView
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureInfo: Identifiable {
|
||||
var icon: String
|
||||
var scale: CGFloat
|
||||
var color: Color
|
||||
var param: String?
|
||||
|
||||
init(_ f: Feature, _ color: Color, _ param: Int?) {
|
||||
self.icon = f.iconFilled
|
||||
self.scale = f.iconScale
|
||||
self.color = color
|
||||
self.param = f.hasParam && param != nil ? timeText(param) : nil
|
||||
}
|
||||
|
||||
var id: String {
|
||||
"\(icon) \(color) \(param ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
private func mergedFeautures() -> [FeatureInfo]? {
|
||||
var fs: [FeatureInfo] = []
|
||||
var icons: Set<String> = []
|
||||
if var i = m.getChatItemIndex(chatItem) {
|
||||
while i < m.reversedChatItems.count,
|
||||
let f = featureInfo(m.reversedChatItems[i]) {
|
||||
if !icons.contains(f.icon) {
|
||||
fs.insert(f, at: 0)
|
||||
icons.insert(f.icon)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return fs.count > 1 ? fs : nil
|
||||
}
|
||||
|
||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||
switch ci.content {
|
||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
||||
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View {
|
||||
let i = Image(systemName: f.icon)
|
||||
.foregroundColor(f.color)
|
||||
.scaleEffect(f.scale)
|
||||
if let param = f.param {
|
||||
HStack {
|
||||
i
|
||||
chatEventText(Text(param)).lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
private var fullFeatureView: some View {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Image(systemName: icon ?? feature.iconFilled)
|
||||
.foregroundColor(iconColor)
|
||||
.scaleEffect(feature.iconScale)
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +31,6 @@ struct CIChatFeatureView: View {
|
||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
||||
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,41 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIEventView: View {
|
||||
var eventText: Text
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
eventText
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
Text(member)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ chatEventText(chatItem)
|
||||
} else {
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
Text(ci.content.text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ ci.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
struct CIEventView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CIEventView(eventText: Text("event happened"))
|
||||
CIEventView(chatItem: ChatItem.getGroupEventSample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFeaturePreferenceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var feature: ChatFeature
|
||||
var allowed: FeatureAllowed
|
||||
@@ -80,6 +80,7 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider {
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,14 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFileView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
@@ -52,7 +51,7 @@ struct CIFileView: View {
|
||||
private var itemInteractive: Bool {
|
||||
if let file = file {
|
||||
switch (file.fileStatus) {
|
||||
case .sndStored: return file.fileProtocol == .local
|
||||
case .sndStored: return false
|
||||
case .sndTransfer: return false
|
||||
case .sndComplete: return false
|
||||
case .sndCancelled: return false
|
||||
@@ -63,7 +62,6 @@ struct CIFileView: View {
|
||||
case .rcvComplete: return true
|
||||
case .rcvCancelled: return false
|
||||
case .rcvError: return false
|
||||
case .invalid: return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -84,7 +82,7 @@ struct CIFileView: View {
|
||||
if fileSizeValid() {
|
||||
Task {
|
||||
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
@@ -107,17 +105,12 @@ struct CIFileView: View {
|
||||
title: "Waiting for file",
|
||||
message: "File will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .local: ()
|
||||
}
|
||||
case .rcvComplete:
|
||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
saveCryptoFile(fileSource)
|
||||
}
|
||||
case .sndStored:
|
||||
logger.debug("CIFileView fileAction - in .sndStored")
|
||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||
saveCryptoFile(fileSource)
|
||||
if let filePath = getLoadedFilePath(file) {
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
default: break
|
||||
}
|
||||
@@ -131,13 +124,11 @@ struct CIFileView: View {
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: fileIcon("doc.fill")
|
||||
case .local: fileIcon("doc.fill")
|
||||
}
|
||||
case let .sndTransfer(sndProgress, sndTotal):
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: progressView()
|
||||
case .local: EmptyView()
|
||||
}
|
||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
@@ -158,7 +149,6 @@ struct CIFileView: View {
|
||||
case .rcvComplete: fileIcon("doc.fill")
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
@@ -201,35 +191,11 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func saveCryptoFile(_ fileSource: CryptoFile) {
|
||||
if let cfArgs = fileSource.cryptoArgs {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
let tempUrl = getTempFilesDirectory().appendingPathComponent(fileSource.filePath)
|
||||
Task {
|
||||
do {
|
||||
try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path)
|
||||
await MainActor.run {
|
||||
showShareSheet(items: [tempUrl]) {
|
||||
removeFile(tempUrl)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
AlertManager.shared.showAlertMsg(title: "Error decrypting file", message: "Error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentFile: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
@@ -242,17 +208,18 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
file: nil
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,45 +17,34 @@ struct CIGroupInvitationView: View {
|
||||
var memberRole: GroupMemberRole
|
||||
var chatIncognito: Bool = false
|
||||
@State private var frameWidth: CGFloat = 0
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var body: some View {
|
||||
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
|
||||
let v = ZStack(alignment: .bottomTrailing) {
|
||||
ZStack {
|
||||
VStack(alignment: .leading) {
|
||||
groupInfoView(action)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
VStack(alignment: .leading) {
|
||||
groupInfoView(action)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(DetermineWidth())
|
||||
|
||||
Divider().frame(width: frameWidth)
|
||||
|
||||
if action {
|
||||
groupInvitationText()
|
||||
.overlay(DetermineWidth())
|
||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||
.foregroundColor(chatIncognito ? .indigo : .accentColor)
|
||||
.font(.callout)
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
} else {
|
||||
groupInvitationText()
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
|
||||
Divider().frame(width: frameWidth)
|
||||
|
||||
if action {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
groupInvitationText()
|
||||
.overlay(DetermineWidth())
|
||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
||||
.font(.callout)
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
} else {
|
||||
groupInvitationText()
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
if progressByTimeout {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
|
||||
.padding(.bottom, 2)
|
||||
chatItem.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -66,24 +55,11 @@ struct CIGroupInvitationView: View {
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
progressByTimeout = inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
}
|
||||
|
||||
if action {
|
||||
v.onTapGesture {
|
||||
inProgress = true
|
||||
joinGroup(groupInvitation.groupId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}
|
||||
.disabled(inProgress)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -91,7 +67,7 @@ struct CIGroupInvitationView: View {
|
||||
|
||||
private func groupInfoView(_ action: Bool) -> some View {
|
||||
var color: Color
|
||||
if action && !inProgress {
|
||||
if action {
|
||||
color = chatIncognito ? .indigo : .accentColor
|
||||
} else {
|
||||
color = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
@@ -10,14 +10,12 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let chatItem: ChatItem
|
||||
let image: String
|
||||
let maxWidth: CGFloat
|
||||
@Binding var imgWidth: CGFloat?
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State var metaColor: Color
|
||||
@State private var showFullScreenImage = false
|
||||
|
||||
var body: some View {
|
||||
@@ -37,9 +35,10 @@ struct CIImageView: View {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
// TODO image accepted alert?
|
||||
}
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
@@ -53,7 +52,6 @@ struct CIImageView: View {
|
||||
title: "Waiting for image",
|
||||
message: "Image will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .local: ()
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
@@ -91,7 +89,6 @@ struct CIImageView: View {
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
case .local: EmptyView()
|
||||
}
|
||||
case .sndTransfer: progressView()
|
||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||
@@ -102,7 +99,6 @@ struct CIImageView: View {
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
@@ -113,7 +109,7 @@ struct CIImageView: View {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(metaColor)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// CIMemberCreatedContactView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 19.09.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMemberCreatedContactView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
switch chatItem.chatDir {
|
||||
case let .groupRcv(groupMember):
|
||||
if let contactId = groupMember.memberContactId {
|
||||
memberCreatedContactView(openText: "Open")
|
||||
.onTapGesture {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
m.chatId = "@\(contactId)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
memberCreatedContactView()
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func memberCreatedContactView(openText: LocalizedStringKey? = nil) -> some View {
|
||||
var r = eventText()
|
||||
if let openText {
|
||||
r = r
|
||||
+ Text(openText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
|
||||
private func eventText() -> Text {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " " + chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
return Text(chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIMemberCreatedContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let content = CIContent.rcvGroupEvent(rcvGroupEvent: .memberCreatedContact)
|
||||
let chatItem = ChatItem(
|
||||
chatDir: .groupRcv(groupMember: GroupMember.sampleData),
|
||||
meta: CIMeta.getSample(1, .now, content.text, .rcvRead),
|
||||
content: content,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIMemberCreatedContactView(chatItem: chatItem)
|
||||
}
|
||||
}
|
||||
@@ -10,51 +10,20 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMetaView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
|
||||
var body: some View {
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
let meta = chatItem.meta
|
||||
let ttl = chat.chatInfo.timedMessagesTTL
|
||||
let encrypted = chatItem.encryptedFile
|
||||
switch meta.itemStatus {
|
||||
case let .sndSent(sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
|
||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
|
||||
}
|
||||
case let .sndRcvd(_, sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
|
||||
}
|
||||
case .partial:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
|
||||
}
|
||||
}
|
||||
default:
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
|
||||
}
|
||||
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SentCheckmark {
|
||||
case sent
|
||||
case rcvd1
|
||||
case rcvd2
|
||||
}
|
||||
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false) -> Text {
|
||||
var r = Text("")
|
||||
if meta.itemEdited {
|
||||
r = r + statusIconText("pencil", color)
|
||||
@@ -68,24 +37,11 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color =
|
||||
r = r + Text(" ")
|
||||
}
|
||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||
let gap = Text(" ").kerning(-1.25)
|
||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||
switch sent {
|
||||
case nil: r = r + t1
|
||||
case .sent: r = r + t1 + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
||||
case .rcvd2: r = r + gap + t1
|
||||
}
|
||||
r = r + Text(" ")
|
||||
r = r + statusIconText(icon, transparent ? .clear : statusColor) + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
if let enc = encrypted {
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||
}
|
||||
r = r + meta.timestampText.foregroundColor(color)
|
||||
return r.font(.caption)
|
||||
return (r + meta.timestampText.foregroundColor(color)).font(.caption)
|
||||
}
|
||||
|
||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
@@ -95,14 +51,11 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,207 +12,25 @@ import SimpleXChat
|
||||
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
|
||||
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
@State private var alert: CIRcvDecryptionErrorAlert?
|
||||
|
||||
enum CIRcvDecryptionErrorAlert: Identifiable {
|
||||
case syncAllowedAlert(_ syncConnection: () -> Void)
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .syncAllowedAlert: return "syncAllowedAlert"
|
||||
case .syncNotSupportedContactAlert: return "syncNotSupportedContactAlert"
|
||||
case .syncNotSupportedMemberAlert: return "syncNotSupportedMemberAlert"
|
||||
case .decryptionErrorAlert: return "decryptionErrorAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
.onAppear {
|
||||
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
|
||||
if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
do {
|
||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
||||
if let s = stats {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
let permanent = Text("This error is permanent for this connection, please re-connect.")
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + Text("\n") + permanent
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why + Text("\n") + permanent
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .syncAllowedAlert(syncConnection): return syncAllowedAlert(syncConnection)
|
||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let mem = m.getGroupMember(groupMember.groupMemberId),
|
||||
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
}
|
||||
} else if !memberStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedMemberAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Decryption error"), message: message))
|
||||
}
|
||||
}
|
||||
|
||||
private func basicDecryptionErrorItem() -> some View {
|
||||
decryptionErrorItem { alert = .decryptionErrorAlert }
|
||||
}
|
||||
|
||||
private func decryptionErrorItemFixButton(syncSupported: Bool, _ onClick: @escaping (() -> Void)) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
}
|
||||
(
|
||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
|
||||
return ZStack(alignment: .bottomTrailing) {
|
||||
HStack {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func message() -> Text {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why
|
||||
case .ratchetEarlier:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .other:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .ratchetSync:
|
||||
message = Text("Encryption re-negotiation failed.")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
private func syncMemberConnection(_ groupInfo: GroupInfo, _ member: GroupMember) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(_ contact: Contact) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, false)
|
||||
await MainActor.run {
|
||||
m.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAllowedAlert(_ syncConnection: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Fix connection?"),
|
||||
message: message(),
|
||||
primaryButton: .default(Text("Fix"), action: syncConnection),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//struct CIRcvDecryptionError_Previews: PreviewProvider {
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SimpleXChat
|
||||
import Combine
|
||||
|
||||
struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
@@ -24,14 +22,10 @@ struct CIVideoView: View {
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var urlDecrypted: URL?
|
||||
@State private var decryptionInProgress: Bool = false
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
@State private var publisher: AnyCancellable? = nil
|
||||
|
||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
||||
self.chatItem = chatItem
|
||||
@@ -41,12 +35,7 @@ struct CIVideoView: View {
|
||||
self._videoWidth = videoWidth
|
||||
self.scrollProxy = scrollProxy
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||
self._urlDecrypted = State(initialValue: decrypted)
|
||||
if let decrypted = decrypted {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
||||
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
||||
}
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
@@ -59,10 +48,8 @@ struct CIVideoView: View {
|
||||
let file = chatItem.file
|
||||
ZStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
videoView(player, decrypted, file, preview, duration)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
if let file = file, let preview = preview, let player = player, let url = url {
|
||||
videoView(player, url, file, preview, duration)
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
@@ -83,7 +70,6 @@ struct CIVideoView: View {
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .local: ()
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
@@ -105,49 +91,14 @@ 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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
||||
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
|
||||
if playingUrl != url {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
@@ -162,20 +113,17 @@ struct CIVideoView: View {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
if canBePlayed {
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
showFullScreenPlayer = true
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
}
|
||||
}
|
||||
loadingIndicator()
|
||||
@@ -202,16 +150,6 @@ struct CIVideoView: View {
|
||||
.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 {
|
||||
HStack {
|
||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||
@@ -255,13 +193,11 @@ struct CIVideoView: View {
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
case .local: EmptyView()
|
||||
}
|
||||
case let .sndTransfer(sndProgress, sndTotal):
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: progressView()
|
||||
case .local: EmptyView()
|
||||
}
|
||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
@@ -276,7 +212,6 @@ struct CIVideoView: View {
|
||||
}
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
@@ -311,11 +246,10 @@ struct CIVideoView: View {
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
await receiveFile(user, file.fileId, false)
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +257,8 @@ struct CIVideoView: View {
|
||||
private func fullScreenPlayer(_ url: URL) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
VideoPlayer(player: fullPlayer)
|
||||
VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
|
||||
}
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button(action: { showFullScreenPlayer = false },
|
||||
label: {
|
||||
@@ -346,52 +281,26 @@ struct CIVideoView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
m.stopPreviousRecPlay = url
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
var played = false
|
||||
publisher = player.publisher(for: \.timeControlStatus).sink { status in
|
||||
if played || status == .playing {
|
||||
AppDelegate.keepScreenOn(status == .playing)
|
||||
AudioPlayer.changeAudioSession(status == .playing)
|
||||
}
|
||||
played = status == .playing
|
||||
}
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let fullScreenTimeObserver = fullScreenTimeObserver {
|
||||
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
|
||||
}
|
||||
fullScreenTimeObserver = nil
|
||||
fullPlayer?.pause()
|
||||
fullPlayer?.seek(to: CMTime.zero)
|
||||
publisher?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
|
||||
let player = AVPlayer(url: url)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
return player
|
||||
}
|
||||
|
||||
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVoiceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@@ -92,7 +91,7 @@ struct CIVoiceView: View {
|
||||
}
|
||||
|
||||
private func metaView() -> some View {
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +144,6 @@ struct VoiceMessagePlayer: View {
|
||||
case .rcvComplete: playbackButton()
|
||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
} else {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
@@ -160,8 +158,7 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
@@ -176,8 +173,8 @@ struct VoiceMessagePlayer: View {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
if let recordingFileName = getLoadedFileName(recordingFile) {
|
||||
startPlayback(recordingFileName)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
@@ -220,7 +217,7 @@ struct VoiceMessagePlayer: View {
|
||||
private func downloadButton(_ recordingFile: CIFile) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
@@ -253,8 +250,8 @@ struct VoiceMessagePlayer: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
@@ -262,7 +259,7 @@ struct VoiceMessagePlayer: View {
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
@@ -271,7 +268,7 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
@@ -285,7 +282,6 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
)
|
||||
Group {
|
||||
CIVoiceView(
|
||||
chat: Chat.sampleData,
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
@@ -294,11 +290,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import SimpleXChat
|
||||
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
@@ -33,8 +36,11 @@ struct DeletedItemView: View {
|
||||
struct DeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(
|
||||
chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
|
||||
showMember: true
|
||||
)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct EmojiItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
@@ -18,7 +17,7 @@ struct EmojiItemView: View {
|
||||
emojiText(chatItem.content.text)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 6)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
@@ -33,8 +32,8 @@ func emojiText(_ text: String) -> Text {
|
||||
struct EmojiItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
|
||||
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
|
||||
@@ -75,25 +75,26 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let voiceMessageWithQuote: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -150,7 +150,7 @@ struct FullScreenMediaView: View {
|
||||
|
||||
private func startPlayerAndNotify() {
|
||||
if let player = player {
|
||||
m.stopPreviousRecPlay = url
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct IntegrityErrorItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var msgError: MsgErrorType
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
CIMsgError(chat: chat, chatItem: chatItem) {
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
switch msgError {
|
||||
case .msgSkipped:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -53,16 +53,19 @@ struct IntegrityErrorItemView: View {
|
||||
}
|
||||
|
||||
struct CIMsgError: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
@@ -76,6 +79,6 @@ struct CIMsgError: View {
|
||||
|
||||
struct IntegrityErrorItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,77 +10,43 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct MarkedDeletedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
|
||||
}
|
||||
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
|
||||
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
|
||||
} else {
|
||||
markedDeletedText("marked deleted")
|
||||
}
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
var mergedMarkedDeletedText: LocalizedStringKey {
|
||||
if !revealed,
|
||||
let ciCategory = chatItem.mergeCategory,
|
||||
var i = m.getChatItemIndex(chatItem) {
|
||||
var moderated = 0
|
||||
var blocked = 0
|
||||
var blockedByAdmin = 0
|
||||
var deleted = 0
|
||||
var moderatedBy: Set<String> = []
|
||||
while i < m.reversedChatItems.count,
|
||||
let ci = .some(m.reversedChatItems[i]),
|
||||
ci.mergeCategory == ciCategory,
|
||||
let itemDeleted = ci.meta.itemDeleted {
|
||||
switch itemDeleted {
|
||||
case let .moderated(_, byGroupMember):
|
||||
moderated += 1
|
||||
moderatedBy.insert(byGroupMember.displayName)
|
||||
case .blocked: blocked += 1
|
||||
case .blockedByAdmin: blockedByAdmin += 1
|
||||
case .deleted: deleted += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
let total = moderated + blocked + blockedByAdmin + deleted
|
||||
return total <= 1
|
||||
? markedDeletedText
|
||||
: total == moderated
|
||||
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
|
||||
: total == blockedByAdmin
|
||||
? "\(total) messages blocked by admin"
|
||||
: total == blocked + blockedByAdmin
|
||||
? "\(total) messages blocked"
|
||||
: "\(total) messages marked deleted"
|
||||
} else {
|
||||
return markedDeletedText
|
||||
}
|
||||
}
|
||||
|
||||
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
|
||||
// can be refactored into a single function if functions calling these are changed to return same type
|
||||
var markedDeletedText: LocalizedStringKey {
|
||||
switch chatItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
||||
case .blocked: "blocked"
|
||||
case .blockedByAdmin: "blocked by admin"
|
||||
case .deleted, nil: "marked deleted"
|
||||
}
|
||||
func markedDeletedText(_ s: LocalizedStringKey) -> some View {
|
||||
Text(s)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import SwiftUI
|
||||
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(" ")
|
||||
|
||||
@@ -25,13 +25,12 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
||||
}
|
||||
|
||||
struct MsgContentView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
var sender: String? = nil
|
||||
var meta: CIMeta? = nil
|
||||
var rightToLeft = false
|
||||
var showSecrets: Bool
|
||||
@State private var typingIdx = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
@@ -63,7 +62,7 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func msgContentView() -> Text {
|
||||
var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
|
||||
var v = messageText(text, formattedText, sender)
|
||||
if let mt = meta {
|
||||
if mt.isLive {
|
||||
v = v + typingIndicator(mt.recent)
|
||||
@@ -81,18 +80,18 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
var res: Text
|
||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||
res = formatText(ft[0], preview, showSecret: showSecrets)
|
||||
if let ft = formattedText, ft.count > 0 {
|
||||
res = formatText(ft[0], preview)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
res = res + formatText(ft[i], preview, showSecret: showSecrets)
|
||||
res = res + formatText(ft[i], preview)
|
||||
i = i + 1
|
||||
}
|
||||
} 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
|
||||
if let f = ft.format {
|
||||
switch (f) {
|
||||
@@ -119,20 +118,16 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
|
||||
case .italic: return Text(t).italic()
|
||||
case .strikeThrough: return Text(t).strikethrough()
|
||||
case .snippet: return Text(t).font(.body.monospaced())
|
||||
case .secret: return
|
||||
showSecret
|
||||
? Text(t)
|
||||
: Text(AttributedString(t, attributes: AttributeContainer([
|
||||
.foregroundColor: UIColor.clear as Any,
|
||||
.backgroundColor: UIColor.secondarySystemFill as Any
|
||||
])))
|
||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
|
||||
switch privacySimplexLinkModeDefault.get() {
|
||||
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
|
||||
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return trustedUri
|
||||
? linkText(t, t, preview, prefix: "")
|
||||
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
|
||||
}
|
||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||
@@ -151,7 +146,7 @@ private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: Stri
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
||||
}
|
||||
|
||||
@@ -159,12 +154,10 @@ struct MsgContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return MsgContentView(
|
||||
chat: Chat.sampleData,
|
||||
text: chatItem.text,
|
||||
formattedText: chatItem.formattedText,
|
||||
sender: chatItem.memberDisplayName,
|
||||
meta: chatItem.meta,
|
||||
showSecrets: false
|
||||
meta: chatItem.meta
|
||||
)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
|
||||
@@ -10,29 +10,10 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@State private var selection: CIInfoTab = .history
|
||||
@State private var alert: CIInfoViewAlert? = nil
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum CIInfoTab {
|
||||
case history
|
||||
case quote
|
||||
case delivery
|
||||
}
|
||||
|
||||
enum CIInfoViewAlert: Identifiable {
|
||||
case alert(title: String, text: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .alert(title, text): return "alert \(title) \(text)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -44,112 +25,53 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch(a) {
|
||||
case let .alert(title, text): return Alert(title: Text(title), message: Text(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
ci.localNote
|
||||
? NSLocalizedString("Saved message", comment: "message info title")
|
||||
: ci.chatDir.sent
|
||||
ci.chatDir.sent
|
||||
? NSLocalizedString("Sent message", comment: "message info title")
|
||||
: NSLocalizedString("Received message", comment: "message info title")
|
||||
}
|
||||
|
||||
private var numTabs: Int {
|
||||
var numTabs = 1
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
if ci.quotedItem != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
return numTabs
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView() -> some View {
|
||||
if numTabs > 1 {
|
||||
TabView(selection: $selection) {
|
||||
if let mdss = chatItemInfo?.memberDeliveryStatuses {
|
||||
deliveryTab(mdss)
|
||||
.tabItem {
|
||||
Label("Delivery", systemImage: "checkmark.message")
|
||||
}
|
||||
.tag(CIInfoTab.delivery)
|
||||
}
|
||||
historyTab()
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.tag(CIInfoTab.history)
|
||||
if let qi = ci.quotedItem {
|
||||
quoteTab(qi)
|
||||
.tabItem {
|
||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.tag(CIInfoTab.quote)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
selection = .delivery
|
||||
}
|
||||
}
|
||||
} else {
|
||||
historyTab()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func details() -> some View {
|
||||
let meta = ci.meta
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
if ci.localNote {
|
||||
infoRow("Created at", localTimestamp(meta.itemTs))
|
||||
} else {
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
}
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func historyTab() -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
Divider().padding(.vertical)
|
||||
|
||||
Text("History")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
@@ -159,21 +81,17 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Text("No history")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
versionText(itemVersion)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, colorScheme))
|
||||
@@ -201,9 +119,9 @@ struct ChatItemInfoView: View {
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||
if text != "" {
|
||||
TextBubble(text: text, formattedText: formattedText, sender: sender)
|
||||
@ViewBuilder private func versionText(_ itemVersion: ChatItemVersion) -> some View {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
messageText(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
@@ -211,157 +129,10 @@ 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 {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("In reply to")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
quotedMsgView(qi, maxWidth)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.contextMenu {
|
||||
if qi.text != "" {
|
||||
Button {
|
||||
showShareSheet(items: [qi.text])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = qi.text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(localTimestamp(qi.sentAt))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
||||
(qi.chatDir?.sent ?? false)
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
}
|
||||
|
||||
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("Delivery")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
memberDeliveryStatusesView(memberDeliveryStatuses)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let mss = membersStatuses(memberDeliveryStatuses)
|
||||
if !mss.isEmpty {
|
||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||
memberDeliveryStatusView(memberStatus.0, memberStatus.1)
|
||||
}
|
||||
} else {
|
||||
Text("No delivery information")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
|
||||
memberDeliveryStatuses.compactMap({ mds in
|
||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||
return (mem.wrapped, mds.memberDeliveryStatus)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 30, height: 30)
|
||||
.padding(.trailing, 2)
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
let v = Group {
|
||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
||||
switch status {
|
||||
case .sndRcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
default:
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(Color.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let (title, text) = status.statusInfo {
|
||||
v.onTapGesture {
|
||||
alert = .alert(title: title, text: text)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func itemInfoShareText() -> String {
|
||||
let meta = ci.meta
|
||||
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
ci.localNote
|
||||
? NSLocalizedString("Created at: %@", comment: "copied message info")
|
||||
: NSLocalizedString("Sent at: %@", comment: "copied message info"),
|
||||
localTimestamp(meta.itemTs))
|
||||
]
|
||||
var shareText: [String] = [title, ""]
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
||||
if !ci.chatDir.sent {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||
}
|
||||
@@ -385,27 +156,9 @@ struct ChatItemInfoView: View {
|
||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
||||
]
|
||||
}
|
||||
if let qi = ci.quotedItem {
|
||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||
let t = qi.text
|
||||
shareText += [""]
|
||||
if let sender = qi.getSender(nil) {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@ at %@:", comment: "copied message info, <sender> at <time>"),
|
||||
sender,
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
} else {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@:", comment: "copied message info"),
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
}
|
||||
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
let t = itemVersion.msgContent.text
|
||||
shareText += [
|
||||
|
||||
@@ -10,8 +10,9 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@Binding var revealed: Bool
|
||||
@@ -19,20 +20,10 @@ struct ChatItemView: View {
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
scrollProxy: ScrollViewProxy? = nil,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false),
|
||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
|
||||
playbackTime: Binding<TimeInterval?> = .constant(nil)
|
||||
) {
|
||||
self.chat = chat
|
||||
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItem = chatItem
|
||||
self.showMember = showMember
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
_revealed = revealed
|
||||
@@ -44,15 +35,15 @@ struct ChatItemView: View {
|
||||
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
||||
if chatItem.meta.itemDeleted != nil && !revealed {
|
||||
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chat: chat, chatItem: ci)
|
||||
EmojiItemView(chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
framedItemView()
|
||||
}
|
||||
@@ -62,17 +53,15 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemContentView<Content: View>: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var showMember: Bool
|
||||
var msgContentView: () -> Content
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
switch chatItem.content {
|
||||
@@ -82,17 +71,10 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .rcvDeleted: deletedItemView()
|
||||
case let .sndCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvIntegrityError(msgError):
|
||||
if developerTools {
|
||||
IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem)
|
||||
} else {
|
||||
ZStack {}
|
||||
}
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvDirectEvent: eventItemView()
|
||||
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
case .sndGroupEvent: eventItemView()
|
||||
case .rcvConnEvent: eventItemView()
|
||||
@@ -100,105 +82,52 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .rcvChatPreference(feature, allowed, param):
|
||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
case let .sndChatPreference(feature, _, _):
|
||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary)
|
||||
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
case .sndModerated: deletedItemView()
|
||||
case .rcvModerated: deletedItemView()
|
||||
case .rcvBlocked: deletedItemView()
|
||||
case let .invalidJSON(json): CIInvalidJSONView(json: json)
|
||||
}
|
||||
}
|
||||
|
||||
private func deletedItemView() -> some View {
|
||||
DeletedItemView(chat: chat, chatItem: chatItem)
|
||||
DeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
}
|
||||
|
||||
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
|
||||
CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration)
|
||||
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration)
|
||||
}
|
||||
|
||||
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
|
||||
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
|
||||
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito)
|
||||
}
|
||||
|
||||
private func eventItemView() -> some View {
|
||||
return CIEventView(eventText: eventItemViewText())
|
||||
}
|
||||
|
||||
private func eventItemViewText() -> Text {
|
||||
if !revealed, let t = mergedGroupEventText {
|
||||
return chatEventText(t + Text(" ") + chatItem.timestampText)
|
||||
} else if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " ")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ chatEventText(chatItem)
|
||||
} else {
|
||||
return chatEventText(chatItem)
|
||||
}
|
||||
CIEventView(chatItem: chatItem)
|
||||
}
|
||||
|
||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||
}
|
||||
|
||||
private var mergedGroupEventText: Text? {
|
||||
let (count, ns) = chatModel.getConnectedMemberNames(chatItem)
|
||||
let members: LocalizedStringKey =
|
||||
switch ns.count {
|
||||
case 1: "\(ns[0]) connected"
|
||||
case 2: "\(ns[0]) and \(ns[1]) connected"
|
||||
case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
|
||||
default:
|
||||
ns.count > 3
|
||||
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
|
||||
: ""
|
||||
}
|
||||
return if count <= 1 {
|
||||
nil
|
||||
} else if ns.count == 0 {
|
||||
Text("\(count) group events")
|
||||
} else if count > ns.count {
|
||||
Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
|
||||
} else {
|
||||
Text(members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ text: Text) -> Text {
|
||||
text
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
||||
chatEventText(Text(eventText) + Text(" ") + ts)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
chatEventText("\(ci.content.text)", ci.timestampText)
|
||||
}
|
||||
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
@@ -210,7 +139,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
|
||||
Group{
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -221,7 +150,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
|
||||
@@ -232,7 +161,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -243,7 +172,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -254,7 +183,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
|
||||
@@ -21,7 +21,9 @@ struct ChatView: View {
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var keyboardVisible = false
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@State private var connectionCode: String?
|
||||
@@ -34,24 +36,9 @@ struct ChatView: View {
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
// opening GroupMemberInfoView on member icon
|
||||
@State private var membersLoaded = false
|
||||
@State private var selectedMember: GMember? = nil
|
||||
// opening GroupLinkView on link button (incognito)
|
||||
@State private var showGroupLinkSheet: Bool = false
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.keyboardPadding()
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return VStack(spacing: 0) {
|
||||
if searchMode {
|
||||
@@ -67,7 +54,6 @@ struct ChatView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
connectingText()
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
@@ -79,14 +65,17 @@ struct ChatView: View {
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
initChatView()
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
if cId != nil {
|
||||
initChatView()
|
||||
} else {
|
||||
dismiss()
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil { dismiss() }
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
@@ -94,10 +83,7 @@ struct ChatView: View {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if chatModel.chatId == nil {
|
||||
chatModel.chatItemStatuses = [:]
|
||||
chatModel.reversedChatItems = []
|
||||
chatModel.groupMembers = []
|
||||
membersLoaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +100,7 @@ struct ChatView: View {
|
||||
connectionStats = stats
|
||||
customUserProfile = profile
|
||||
connectionCode = code
|
||||
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
|
||||
if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
|
||||
chat.chatInfo = .direct(contact: ct)
|
||||
}
|
||||
}
|
||||
@@ -135,47 +121,38 @@ struct ChatView: View {
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showChatInfoSheet = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet) {
|
||||
GroupChatInfoView(
|
||||
chat: chat,
|
||||
groupInfo: Binding(
|
||||
get: { groupInfo },
|
||||
set: { gInfo in
|
||||
chat.chatInfo = .group(groupInfo: gInfo)
|
||||
chat.created = Date.now
|
||||
}
|
||||
)
|
||||
)
|
||||
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
if callsPrefEnabled {
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
Menu {
|
||||
if callsPrefEnabled {
|
||||
if contact.allowsFeature(.calls) {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -184,16 +161,9 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
groupLinkButton()
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false
|
||||
)
|
||||
}
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
|
||||
} else {
|
||||
addMembersButton()
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
@@ -208,8 +178,6 @@ struct ChatView: View {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
case .local:
|
||||
searchButton()
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -217,46 +185,9 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
if chatModel.chatId == groupInfo.id {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
membersLoaded = true
|
||||
updateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initChatView() {
|
||||
let cInfo = chat.chatInfo
|
||||
if case let .direct(contact) = cInfo {
|
||||
Task {
|
||||
do {
|
||||
let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
if let s = stats {
|
||||
chatModel.updateContactConnectionStats(contact, s)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiContactInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
HStack {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
TextField("Search", text: $searchText)
|
||||
.focused($searchFocussed)
|
||||
@@ -269,9 +200,9 @@ struct ChatView: View {
|
||||
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)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
Button ("Cancel") {
|
||||
@@ -297,7 +228,7 @@ struct ChatView: View {
|
||||
return GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
@@ -349,20 +280,6 @@ struct ChatView: View {
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
contact.active,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
|
||||
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
|
||||
@@ -436,32 +353,19 @@ struct ChatView: View {
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showAddMembersSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task {
|
||||
do {
|
||||
if let link = try apiGetGroupLink(gInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
showGroupLinkSheet = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "link.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
||||
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
||||
if loadingItems || firstPage { return }
|
||||
@@ -491,149 +395,86 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
ChatItemWithMenu(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
chatView: self
|
||||
)
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture { selectedMember = member }
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
ChatItemWithMenu(
|
||||
ci: ci,
|
||||
showMember: showMember,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.padding(.leading, 8)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
ChatItemWithMenu(
|
||||
ci: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var ci: ChatItem
|
||||
var showMember: Bool = false
|
||||
var maxWidth: CGFloat
|
||||
var scrollProxy: ScrollViewProxy?
|
||||
var deleteMessage: (CIDeleteMode) -> Void
|
||||
@Binding var deletingItem: ChatItem?
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
var chatView: ChatView
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var deletingItems: [Int64] = []
|
||||
@State private var showDeleteMessages = false
|
||||
@Binding var showDeleteMessage: Bool
|
||||
|
||||
@State private var revealed = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State private var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
let (currIndex, nextItem) = m.getNextChatItem(chatItem)
|
||||
let ciCategory = chatItem.mergeCategory
|
||||
if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) {
|
||||
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
|
||||
ZStack {} // scroll doesn't work if it's EmptyView()
|
||||
} else {
|
||||
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
let range = itemsRange(currIndex, prevHidden)
|
||||
if revealed, let range = range {
|
||||
let items = Array(zip(Array(range), m.reversedChatItems[range]))
|
||||
ForEach(items, id: \.1.viewId) { (i, ci) in
|
||||
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev)
|
||||
}
|
||||
} else {
|
||||
chatItemView(chatItem, range, prevItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevMember, memCount): (GroupMember?, Int) =
|
||||
if let range = range {
|
||||
m.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
(nil, 1)
|
||||
}
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, memberImageSize + 14)
|
||||
.padding(.top, 7)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture {
|
||||
if chatView.membersLoaded {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
} else {
|
||||
Task {
|
||||
await chatView.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
}
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey {
|
||||
let name = member.displayName
|
||||
return if let prevName = prevMember?.displayName {
|
||||
memCount > 2
|
||||
? "\(name), \(prevName) and \(memCount - 2) members"
|
||||
: "\(name) and \(prevName)"
|
||||
} else {
|
||||
"\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) },
|
||||
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
|
||||
set: { _ in }
|
||||
)
|
||||
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: chatView.scrollProxy,
|
||||
revealed: $revealed,
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
.accessibilityLabel("")
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions(ci)
|
||||
chatItemReactions()
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
@@ -641,17 +482,12 @@ struct ChatView: View {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
}
|
||||
if let di = deletingItem, di.meta.editable && !di.localNote {
|
||||
if let di = deletingItem, di.meta.editable {
|
||||
Button(broadcastDeleteButtonText, role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessages()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.onDisappear {
|
||||
@@ -669,15 +505,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemReactions(_ ci: ChatItem) -> some View {
|
||||
private func chatItemReactions() -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ci.reactions, id: \.reaction) { r in
|
||||
let v = HStack(spacing: 4) {
|
||||
@@ -697,7 +525,7 @@ struct ChatView: View {
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
v.onTapGesture {
|
||||
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
|
||||
setReaction(add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
@@ -706,10 +534,10 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> [UIMenuElement] {
|
||||
private func menu(live: Bool) -> [UIMenuElement] {
|
||||
var menu: [UIMenuElement] = []
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
|
||||
let rs = allReactions(ci)
|
||||
let rs = allReactions()
|
||||
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
|
||||
rs.count > 0 {
|
||||
var rm: UIMenu
|
||||
@@ -725,67 +553,53 @@ struct ChatView: View {
|
||||
}
|
||||
menu.append(rm)
|
||||
}
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote {
|
||||
menu.append(replyUIAction(ci))
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction())
|
||||
}
|
||||
let fileSource = getLoadedFileSource(ci.file)
|
||||
let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false }
|
||||
let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists)
|
||||
if copyAndShareAllowed {
|
||||
menu.append(shareUIAction(ci))
|
||||
menu.append(copyUIAction(ci))
|
||||
}
|
||||
if let fileSource = fileSource, fileExists {
|
||||
menu.append(shareUIAction())
|
||||
menu.append(copyUIAction())
|
||||
if let filePath = getLoadedFilePath(ci.file) {
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
if image.imageData != nil {
|
||||
menu.append(saveFileAction(fileSource))
|
||||
menu.append(saveFileAction(filePath))
|
||||
} else {
|
||||
menu.append(saveImageAction(image))
|
||||
}
|
||||
} else {
|
||||
menu.append(saveFileAction(fileSource))
|
||||
menu.append(saveFileAction(filePath))
|
||||
}
|
||||
}
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction(ci))
|
||||
}
|
||||
if !ci.isLiveDummy {
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(editAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
if ci.meta.itemDeleted == nil && !ci.localNote,
|
||||
if ci.meta.itemDeleted == nil,
|
||||
let file = ci.file,
|
||||
let cancelAction = file.cancelAction {
|
||||
let cancelAction = file.cancelAction {
|
||||
menu.append(cancelFileUIAction(file.fileId, cancelAction))
|
||||
}
|
||||
if !live || !ci.meta.isLive {
|
||||
menu.append(deleteUIAction(ci))
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
|
||||
menu.append(moderateUIAction(ci, groupInfo))
|
||||
menu.append(moderateUIAction(groupInfo))
|
||||
}
|
||||
} else if ci.meta.itemDeleted != nil {
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
} else if !ci.isDeletedContent {
|
||||
if !ci.isDeletedContent {
|
||||
menu.append(revealUIAction())
|
||||
} else if range != nil {
|
||||
menu.append(expandUIAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(deleteUIAction(ci))
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(deleteUIAction(ci))
|
||||
} else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) {
|
||||
menu.append(revealed ? shrinkUIAction() : expandUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func replyUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func replyUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||
@@ -802,7 +616,7 @@ struct ChatView: View {
|
||||
|
||||
private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu {
|
||||
UIMenu(
|
||||
title: NSLocalizedString("React…", comment: "chat item menu"),
|
||||
title: NSLocalizedString("React...", comment: "chat item menu"),
|
||||
image: UIImage(systemName: "face.smiling"),
|
||||
children: rs
|
||||
)
|
||||
@@ -820,11 +634,11 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func allReactions(_ ci: ChatItem) -> [UIAction] {
|
||||
private func allReactions() -> [UIAction] {
|
||||
MsgReaction.values.compactMap { r in
|
||||
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
|
||||
? nil
|
||||
: UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) }
|
||||
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,7 +646,7 @@ struct ChatView: View {
|
||||
rs.count > 4 ? 3 : 4
|
||||
}
|
||||
|
||||
private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) {
|
||||
private func setReaction(add: Bool, reaction: MsgReaction) {
|
||||
Task {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -844,7 +658,7 @@ struct ChatView: View {
|
||||
reaction: reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
m.updateChatItem(chat.chatInfo, chatItem)
|
||||
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiChatItemReaction error: \(responseError(error))")
|
||||
@@ -852,7 +666,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func shareUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func shareUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.up")
|
||||
@@ -865,7 +679,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func copyUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func copyUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
@@ -889,16 +703,17 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveFileAction(_ fileSource: CryptoFile) -> UIAction {
|
||||
private func saveFileAction(_ filePath: String) -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open")
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
saveCryptoFile(fileSource)
|
||||
let fileURL = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [fileURL])
|
||||
}
|
||||
}
|
||||
|
||||
private func editAction(_ ci: ChatItem) -> UIAction {
|
||||
private func editAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.pencil")
|
||||
@@ -909,7 +724,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func viewInfoUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func viewInfoUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Info", comment: "chat item action"),
|
||||
image: UIImage(systemName: "info.circle")
|
||||
@@ -921,9 +736,6 @@ struct ChatView: View {
|
||||
await MainActor.run {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
await chatView.loadGroupMembers(gInfo)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
}
|
||||
@@ -943,7 +755,7 @@ struct ChatView: View {
|
||||
message: Text(cancelAction.alert.message),
|
||||
primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await cancelFile(user: user, fileId: fileId)
|
||||
}
|
||||
}
|
||||
@@ -964,45 +776,18 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func deleteUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
if !revealed && ci.meta.itemDeleted != nil,
|
||||
let currIndex = m.getChatItemIndex(ci),
|
||||
let ciCategory = ci.mergeCategory {
|
||||
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
if let range = itemsRange(currIndex, prevHidden) {
|
||||
var itemIds: [Int64] = []
|
||||
for i in range {
|
||||
itemIds.append(m.reversedChatItems[i].id)
|
||||
}
|
||||
showDeleteMessages = true
|
||||
deletingItems = itemIds
|
||||
} else {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
} else {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
}
|
||||
|
||||
private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange<Int>? {
|
||||
if let currIndex = currIndex,
|
||||
let prevHidden = prevHidden,
|
||||
prevHidden > currIndex {
|
||||
currIndex...prevHidden
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
|
||||
private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Moderate", comment: "chat item action"),
|
||||
image: UIImage(systemName: "flag"),
|
||||
@@ -1034,105 +819,20 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func expandUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Expand", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
revealed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shrinkUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Hide", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var broadcastDeleteButtonText: LocalizedStringKey {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
|
||||
var deleteMessagesTitle: LocalizedStringKey {
|
||||
let n = deletingItems.count
|
||||
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
|
||||
}
|
||||
|
||||
private func deleteMessages() {
|
||||
let itemIds = deletingItems
|
||||
if itemIds.count > 0 {
|
||||
let chatInfo = chat.chatInfo
|
||||
Task {
|
||||
var deletedItems: [ChatItem] = []
|
||||
for itemId in itemIds {
|
||||
do {
|
||||
let (di, _) = try await apiDeleteChatItem(
|
||||
type: chatInfo.chatType,
|
||||
id: chatInfo.apiId,
|
||||
itemId: itemId,
|
||||
mode: .cidmInternal
|
||||
)
|
||||
deletedItems.append(di)
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
for di in deletedItems {
|
||||
m.removeChatItem(chatInfo, di)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
do {
|
||||
if let di = deletingItem {
|
||||
var deletedItem: ChatItem
|
||||
var toItem: ChatItem?
|
||||
if case .cidmBroadcast = mode,
|
||||
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
|
||||
groupId: groupInfo.apiId,
|
||||
groupMemberId: groupMember.groupMemberId,
|
||||
itemId: di.id
|
||||
)
|
||||
} else {
|
||||
(deletedItem, toItem) = try await apiDeleteChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: di.id,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
deletingItem = nil
|
||||
if let toItem = toItem {
|
||||
_ = m.upsertChatItem(chat.chatInfo, toItem)
|
||||
} else {
|
||||
m.removeChatItem(chat.chatInfo, deletedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
if let ci = chatModel.reversedChatItems.first {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
@@ -1144,6 +844,44 @@ struct ChatView: View {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
do {
|
||||
if let di = deletingItem {
|
||||
var deletedItem: ChatItem
|
||||
var toItem: ChatItem?
|
||||
if case .cidmBroadcast = mode,
|
||||
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
|
||||
groupId: groupInfo.apiId,
|
||||
groupMemberId: groupMember.groupMemberId,
|
||||
itemId: di.id
|
||||
)
|
||||
} else {
|
||||
(deletedItem, toItem) = try await apiDeleteChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: di.id,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
deletingItem = nil
|
||||
if let toItem = toItem {
|
||||
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
|
||||
} else {
|
||||
chatModel.removeChatItem(chat.chatInfo, deletedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
|
||||
@@ -1160,7 +898,7 @@ struct ChatView: View {
|
||||
|
||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.enableNtfs = enableNtfs ? .all : .none
|
||||
chatSettings.enableNtfs = enableNtfs
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ struct ComposeState {
|
||||
var contextItem: ComposeContextItem
|
||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||
var inProgress = false
|
||||
var disabled = false
|
||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
|
||||
init(
|
||||
@@ -104,7 +105,7 @@ struct ComposeState {
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case let .mediaPreviews(media): return !media.isEmpty
|
||||
case .mediaPreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || liveMessage != nil
|
||||
@@ -167,23 +168,25 @@ struct ComposeState {
|
||||
}
|
||||
|
||||
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
let chatItemPreview: ComposePreview
|
||||
switch chatItem.content.msgContent {
|
||||
case .text:
|
||||
return .noPreview
|
||||
chatItemPreview = .noPreview
|
||||
case let .link(_, preview: preview):
|
||||
return .linkPreview(linkPreview: preview)
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image):
|
||||
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .video(_, image, _):
|
||||
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .voice(_, duration):
|
||||
return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
case .file:
|
||||
let fileName = chatItem.file?.fileName ?? ""
|
||||
return .filePreview(fileName: fileName, file: getAppFilePath(fileName))
|
||||
chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName))
|
||||
default:
|
||||
return .noPreview
|
||||
chatItemPreview = .noPreview
|
||||
}
|
||||
return chatItemPreview
|
||||
}
|
||||
|
||||
enum UploadContent: Equatable {
|
||||
@@ -231,14 +234,13 @@ struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var keyboardVisible: Bool
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
|
||||
@State var linkUrl: URL? = nil
|
||||
@State var prevLinkUrl: URL? = nil
|
||||
@State var pendingLinkUrl: URL? = nil
|
||||
@State var cancelledLinks: Set<String> = []
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showChooseSource = false
|
||||
@State private var showMediaPicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@@ -253,13 +255,8 @@ struct ComposeView: View {
|
||||
// this is a workaround to fire an explicit event in certain cases
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
ContextInvitingContactMemberView()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
@@ -273,7 +270,7 @@ struct ComposeView: View {
|
||||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend)
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
@@ -295,13 +292,12 @@ struct ComposeView: View {
|
||||
sendMessage(ttl: ttl)
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
updateLiveMessage: updateLiveMessage,
|
||||
cancelLiveMessage: {
|
||||
composeState.liveMessage = nil
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
@@ -313,10 +309,7 @@ struct ComposeView: View {
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible,
|
||||
sendButtonColor: chat.chatInfo.incognito
|
||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||
: .accentColor
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.background(.background)
|
||||
@@ -384,10 +377,10 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in
|
||||
await MainActor.run {
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
DispatchQueue.main.async {
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
||||
}
|
||||
}
|
||||
@@ -449,15 +442,7 @@ struct ComposeView: View {
|
||||
} else if (composeState.inProgress) {
|
||||
clearCurrentDraft()
|
||||
} else if !composeState.empty {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
if saveLastDraft {
|
||||
saveCurrentDraft()
|
||||
}
|
||||
saveCurrentDraft()
|
||||
} else {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
@@ -488,30 +473,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 {
|
||||
getMaxFileSize(.xftp)
|
||||
}
|
||||
@@ -616,14 +577,12 @@ struct ComposeView: View {
|
||||
EmptyView()
|
||||
case let .quotedItem(chatItem: quotedItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItem: quotedItem,
|
||||
contextIcon: "arrowshape.turn.up.left",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItem: editingItem,
|
||||
contextIcon: "pencil",
|
||||
cancelContextItem: { clearState() }
|
||||
@@ -647,9 +606,7 @@ struct ComposeView: View {
|
||||
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
||||
await sending()
|
||||
}
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
await sendMemberContactInvitation()
|
||||
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||
if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
@@ -686,8 +643,8 @@ struct ComposeView: View {
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
let file = voiceCryptoFile(recordingFileName)
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
|
||||
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
@@ -698,19 +655,9 @@ struct ComposeView: View {
|
||||
return sent
|
||||
|
||||
func sending() async {
|
||||
await MainActor.run { composeState.inProgress = true }
|
||||
}
|
||||
|
||||
func sendMemberContactInvitation() async {
|
||||
do {
|
||||
let mc = checkLinkPreview()
|
||||
let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
|
||||
await MainActor.run {
|
||||
self.chatModel.updateContact(contact)
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
|
||||
await MainActor.run { composeState.disabled = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if composeState.disabled { composeState.inProgress = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,21 +665,17 @@ struct ComposeView: View {
|
||||
if let oldMsgContent = ei.content.msgContent {
|
||||
do {
|
||||
let mc = updateMsgContent(oldMsgContent)
|
||||
if mc != oldMsgContent || (ei.meta.itemLive ?? false) {
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} else {
|
||||
return nil
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} catch {
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
||||
@@ -770,39 +713,22 @@ struct ComposeView: View {
|
||||
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
||||
if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
||||
if !privacyEncryptLocalFilesGroupDefault.get() {
|
||||
return CryptoFile.plain(fileName)
|
||||
}
|
||||
let url = getAppFilePath(fileName)
|
||||
let toFile = generateNewFileName("voice", "m4a")
|
||||
let toUrl = getAppFilePath(toFile)
|
||||
if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) {
|
||||
removeFile(url)
|
||||
return CryptoFile(filePath: toFile, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
if let chatItem = chat.chatInfo.chatType == .local
|
||||
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
|
||||
: await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
file: file,
|
||||
quotedItemId: quoted,
|
||||
msg: mc,
|
||||
live: live,
|
||||
ttl: ttl
|
||||
) {
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
if let chatItem = await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
file: file,
|
||||
quotedItemId: quoted,
|
||||
msg: mc,
|
||||
live: live,
|
||||
ttl: ttl
|
||||
) {
|
||||
await MainActor.run {
|
||||
chatModel.removeLiveDummy(animated: false)
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
@@ -810,7 +736,7 @@ struct ComposeView: View {
|
||||
return chatItem
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file.filePath)
|
||||
removeFile(file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -830,7 +756,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
|
||||
func saveAnyImage(_ img: UploadContent) -> String? {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
@@ -922,6 +848,7 @@ struct ComposeView: View {
|
||||
|
||||
private func clearState(live: Bool = false) {
|
||||
if live {
|
||||
composeState.disabled = false
|
||||
composeState.inProgress = false
|
||||
} else {
|
||||
composeState = ComposeState()
|
||||
@@ -934,6 +861,12 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private func saveCurrentDraft() {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
chatModel.draft = composeState
|
||||
chatModel.draftChatId = chat.id
|
||||
}
|
||||
@@ -978,9 +911,6 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private func cancelLinkPreview() {
|
||||
if let pendingLink = pendingLinkUrl?.absoluteString {
|
||||
cancelledLinks.insert(pendingLink)
|
||||
}
|
||||
if let uri = composeState.linkPreview?.uri.absoluteString {
|
||||
cancelledLinks.insert(uri)
|
||||
}
|
||||
@@ -1013,18 +943,19 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
@State var composeState = ComposeState(message: "hello")
|
||||
@FocusState var keyboardVisible: Bool
|
||||
|
||||
return Group {
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ struct ComposeVoiceView: View {
|
||||
playbackTime = recordingTime // animate progress bar to the end
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// ContextInvitingContactMemberView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 18.09.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContextInvitingContactMemberView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "message")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Send direct message to connect")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextInvitingContactMemberView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContextInvitingContactMemberView()
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct ContextItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
let contextItem: ChatItem
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
@@ -23,14 +22,13 @@ struct ContextItemView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(.secondary)
|
||||
if let sender = contextItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
||||
msgContentView(lines: 2)
|
||||
}
|
||||
} else {
|
||||
msgContentView(lines: 3)
|
||||
}
|
||||
MsgContentView(
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText,
|
||||
sender: contextItem.memberDisplayName
|
||||
)
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(3)
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation {
|
||||
@@ -46,22 +44,11 @@ struct ContextItemView: View {
|
||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
MsgContentView(
|
||||
chat: chat,
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText,
|
||||
showSecrets: false
|
||||
)
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(lines)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,20 @@ import PhotosUI
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var disableEditing: Bool
|
||||
@Binding var height: CGFloat
|
||||
@Binding var focused: Bool
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@FocusState.Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
private let minHeight: CGFloat = 37
|
||||
|
||||
private let defaultHeight: CGFloat = {
|
||||
let field = CustomUITextField(height: Binding.constant(0))
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
|
||||
}()
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField(height: _height)
|
||||
let field = CustomUITextField()
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
if !disableEditing {
|
||||
// Speed up the process of updating layout, reduce jumping content on screen
|
||||
if !isShortEmoji(newText) { updateHeight(field) }
|
||||
text = newText
|
||||
} else {
|
||||
field.text = text
|
||||
@@ -47,72 +39,24 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
}
|
||||
|
||||
private func updateHeight(_ field: UITextView) {
|
||||
let maxHeight = min(360, field.font!.lineHeight * 12)
|
||||
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
|
||||
let newHeight = field.text == ""
|
||||
? defaultHeight
|
||||
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
|
||||
|
||||
if field.frame.size.height != newHeight {
|
||||
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
|
||||
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFont(_ field: UITextView) {
|
||||
field.font = isShortEmoji(field.text)
|
||||
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
||||
: UIFont.preferredFont(forTextStyle: .body)
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var height: Binding<CGFloat>
|
||||
var newHeight: CGFloat = 0
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
init(height: Binding<CGFloat>) {
|
||||
self.height = height
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
// This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft),
|
||||
// produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call
|
||||
// (who knows why...)
|
||||
func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) {
|
||||
self.newHeight = newHeight
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
if height.wrappedValue != newHeight {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
|
||||
}
|
||||
return CGSizeMake(0, newHeight)
|
||||
}
|
||||
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
}
|
||||
@@ -200,14 +144,15 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
static var previews: some View {
|
||||
NativeTextEditor(
|
||||
@FocusState var keyboardVisible: Bool
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
disableEditing: Binding.constant(false),
|
||||
height: Binding.constant(100),
|
||||
focused: Binding.constant(false),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: $keyboardVisible,
|
||||
alignment: TextAlignment.leading,
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ struct SendMessageView: View {
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
var cancelLiveMessage: (() -> Void)? = nil
|
||||
var nextSendGrpInv: Bool = false
|
||||
var showVoiceMessageButton: Bool = true
|
||||
var voiceMessageAllowed: Bool = true
|
||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||
@@ -28,16 +27,17 @@ struct SendMessageView: View {
|
||||
var onMediaAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@Binding var keyboardVisible: Bool
|
||||
var sendButtonColor = Color.accentColor
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
@State private var showCustomDisappearingMessageDialogue = false
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
@State private var progressByTimeout = false
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
@@ -54,20 +54,34 @@ struct SendMessageView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
||||
Text(composeState.message)
|
||||
.lineLimit(10)
|
||||
.font(teFont)
|
||||
.multilineTextAlignment(alignment)
|
||||
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
|
||||
// .foregroundColor(.red)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
height: teHeight,
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
}
|
||||
|
||||
if progressByTimeout {
|
||||
if composeState.inProgress {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
@@ -83,30 +97,17 @@ struct SendMessageView: View {
|
||||
.frame(height: teHeight, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.overlay(
|
||||
|
||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
}
|
||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
progressByTimeout = composeState.inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder private func composeActionButtons() -> some View {
|
||||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if nextSendGrpInv {
|
||||
inviteMemberContactButton()
|
||||
} else if showVoiceMessageButton
|
||||
if showVoiceMessageButton
|
||||
&& composeState.message.isEmpty
|
||||
&& !composeState.editing
|
||||
&& composeState.liveMessage == nil
|
||||
@@ -118,7 +119,7 @@ struct SendMessageView: View {
|
||||
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
holdingVMR: $holdingVMR,
|
||||
disabled: composeState.inProgress
|
||||
disabled: composeState.disabled
|
||||
)
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
@@ -150,24 +151,6 @@ struct SendMessageView: View {
|
||||
.padding([.top, .trailing], 4)
|
||||
}
|
||||
|
||||
private func inviteMemberContactButton() -> some View {
|
||||
Button {
|
||||
sendMessage(nil)
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(sendButtonColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.inProgress
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func sendMessageButton() -> some View {
|
||||
Button {
|
||||
sendMessage(nil)
|
||||
@@ -176,13 +159,13 @@ struct SendMessageView: View {
|
||||
? "checkmark.circle.fill"
|
||||
: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(sendButtonColor)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.inProgress ||
|
||||
composeState.disabled ||
|
||||
(!voiceMessageAllowed && composeState.voicePreview) ||
|
||||
composeState.endLiveDisabled
|
||||
)
|
||||
@@ -310,7 +293,7 @@ struct SendMessageView: View {
|
||||
Image(systemName: "mic")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
@@ -395,17 +378,21 @@ struct SendMessageView: View {
|
||||
Image(systemName: "stop.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func updateFont(_ text: String) {
|
||||
private func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
DispatchQueue.main.async {
|
||||
teFont = isShortEmoji(text)
|
||||
? (text.count < 4 ? largeEmojiFont : mediumEmojiFont)
|
||||
: .body
|
||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||
(teFont, teUiFont) = isShortEmoji(composeState.message)
|
||||
? composeState.message.count < 4
|
||||
? (largeEmojiFont, largeEmojiUIFont)
|
||||
: (mediumEmojiFont, mediumEmojiUIFont)
|
||||
: (.body, UIFont.preferredFont(forTextStyle: .body))
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +401,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
@State var composeStateNew = ComposeState()
|
||||
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
@State var composeStateEditing = ComposeState(editingItem: ci)
|
||||
@FocusState var keyboardVisible: Bool
|
||||
@State var sendEnabled: Bool = true
|
||||
|
||||
return Group {
|
||||
@@ -424,7 +412,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
VStack {
|
||||
@@ -434,7 +422,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ struct ContactPreferencesView: View {
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
|
||||
@@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
do {
|
||||
for contactId in selectedContacts {
|
||||
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
|
||||
await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) }
|
||||
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
|
||||
}
|
||||
addedMembersCb(selectedContacts)
|
||||
} catch {
|
||||
@@ -157,7 +157,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
private func rolePicker() -> some View {
|
||||
Picker("New member role", selection: $selectedRole) {
|
||||
ForEach(GroupMemberRole.allCases) { role in
|
||||
if role <= groupInfo.membership.memberRole && role != .author {
|
||||
if role <= groupInfo.membership.memberRole {
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,18 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
||||
|
||||
struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
@@ -33,35 +30,14 @@ struct GroupChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case leaveGroupAlert
|
||||
case cantInviteIncognitoAlert
|
||||
case largeGroupReceiptsDisabled
|
||||
case blockMemberAlert(mem: GroupMember)
|
||||
case unblockMemberAlert(mem: GroupMember)
|
||||
case blockForAllAlert(mem: GroupMember)
|
||||
case unblockForAllAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleteGroupAlert: return "deleteGroupAlert"
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .leaveGroupAlert: return "leaveGroupAlert"
|
||||
case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert"
|
||||
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
|
||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(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 .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
var id: GroupChatInfoViewAlert { get { self } }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let members = chatModel.groupMembers
|
||||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
|
||||
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||
|
||||
List {
|
||||
@@ -76,11 +52,6 @@ struct GroupChatInfoView: View {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
@@ -103,17 +74,17 @@ struct GroupChatInfoView: View {
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
memberView(groupInfo.membership, user: true)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
memberInfoView(member.groupMemberId)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
memberView(member)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,20 +115,9 @@ struct GroupChatInfoView: View {
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .leaveGroupAlert: return leaveGroupAlert()
|
||||
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
|
||||
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
||||
case let .blockMemberAlert(mem): return blockMemberAlert(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 .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
do {
|
||||
if let link = try apiGetGroupLink(groupInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
@@ -166,7 +126,6 @@ struct GroupChatInfoView: View {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
private func groupInfoHeader() -> some View {
|
||||
@@ -178,14 +137,12 @@ struct GroupChatInfoView: View {
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(8)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -199,7 +156,7 @@ struct GroupChatInfoView: View {
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,144 +165,51 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MemberRowView: View {
|
||||
var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
var user: Bool = false
|
||||
@Binding var alert: GroupChatInfoViewAlert?
|
||||
|
||||
var body: some View {
|
||||
let member = groupMember.wrapped
|
||||
let v = HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 38, height: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
memberInfo(member)
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
if user {
|
||||
v
|
||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeSwipe(member, blockSwipe(member, v))
|
||||
} else {
|
||||
blockSwipe(member, v)
|
||||
}
|
||||
// revert to this: vvv
|
||||
// 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")
|
||||
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 38, height: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
let role = member.memberRole
|
||||
if [.owner, .admin, .observer].contains(role) {
|
||||
Text(member.memberRole.text)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
|
||||
v.swipeActions(edge: .leading) {
|
||||
if member.memberSettings.showMessages {
|
||||
Button {
|
||||
alert = .blockMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
alert = .unblockMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
v.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
alert = .removeMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
Spacer()
|
||||
let role = member.memberRole
|
||||
if role == .owner || role == .admin {
|
||||
Text(member.memberRole.text)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
|
||||
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
if groupLink == nil {
|
||||
Label("Create group link", systemImage: "link.badge.plus")
|
||||
@@ -370,11 +234,7 @@ struct GroupChatInfoView: View {
|
||||
|
||||
private func addOrEditWelcomeMessage() -> some View {
|
||||
NavigationLink {
|
||||
GroupWelcomeView(
|
||||
groupInfo: $groupInfo,
|
||||
groupProfile: groupInfo.groupProfile,
|
||||
welcomeText: groupInfo.groupProfile.description ?? ""
|
||||
)
|
||||
GroupWelcomeView(groupId: groupInfo.groupId, groupInfo: $groupInfo)
|
||||
.navigationTitle("Welcome message")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
@@ -421,9 +281,9 @@ struct GroupChatInfoView: View {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
chatModel.chatId = nil
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
@@ -465,60 +325,6 @@ struct GroupChatInfoView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func sendReceiptsOptionDisabled() -> some View {
|
||||
HStack {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
Spacer()
|
||||
Text("disabled")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onTapGesture {
|
||||
alert = .largeGroupReceiptsDisabled
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||
@@ -540,14 +346,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
||||
}
|
||||
}
|
||||
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
func cantInviteIncognitoAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Can't invite contacts!"),
|
||||
@@ -555,18 +353,8 @@ func cantInviteIncognitoAlert() -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func largeGroupReceiptsDisabledAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Receipts are disabled"),
|
||||
message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
||||
)
|
||||
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,8 @@ struct GroupLinkView: View {
|
||||
var groupId: Int64
|
||||
@Binding var groupLink: String?
|
||||
@Binding var groupLinkMemberRole: GroupMemberRole
|
||||
var showTitle: Bool = false
|
||||
var creatingGroup: Bool = false
|
||||
var linkCreatedCb: (() -> Void)? = nil
|
||||
@State private var creatingLink = false
|
||||
@State private var alert: GroupLinkAlert?
|
||||
@State private var shouldCreate = true
|
||||
|
||||
private enum GroupLinkAlert: Identifiable {
|
||||
case deleteLink
|
||||
@@ -33,35 +29,10 @@ struct GroupLinkView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
NavigationView {
|
||||
groupLinkView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupLinkView()
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
if showTitle {
|
||||
Text("Group link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
Section {
|
||||
if let groupLink = groupLink {
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
@@ -70,18 +41,15 @@ struct GroupLinkView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
SimpleXLinkQRCode(uri: groupLink)
|
||||
.id("simplex-qrcode-view-for-\(groupLink)")
|
||||
QRCode(uri: groupLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(groupLink)])
|
||||
showShareSheet(items: [groupLink])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
if !creatingGroup {
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
} else {
|
||||
Button(action: createGroupLink) {
|
||||
@@ -127,10 +95,9 @@ struct GroupLinkView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if groupLink == nil && !creatingLink && shouldCreate {
|
||||
if groupLink == nil && !creatingLink {
|
||||
createGroupLink()
|
||||
}
|
||||
shouldCreate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,44 +12,34 @@ import SimpleXChat
|
||||
struct GroupMemberInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
var groupInfo: GroupInfo
|
||||
@State var member: GroupMember
|
||||
var navigation: Bool = false
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var justOpened = true
|
||||
@State private var progressIndicator = false
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case blockMemberAlert(mem: GroupMember)
|
||||
case unblockMemberAlert(mem: GroupMember)
|
||||
case blockForAllAlert(mem: GroupMember)
|
||||
case unblockForAllAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(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 .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
|
||||
case .removeMemberAlert: return "removeMemberAlert"
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,195 +62,155 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
VStack {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton()
|
||||
}
|
||||
newDirectChatButton(contactId)
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
if let connStats = connectionStats {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
if member.memberSettings.showMessages {
|
||||
blockMemberButton(member)
|
||||
} else {
|
||||
unblockMemberButton(member)
|
||||
}
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
removeMemberButton(member)
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if groupInfo.membership.memberRole >= .admin {
|
||||
// adminDestructiveSection(member)
|
||||
// } else {
|
||||
// nonAdminBlockSection(member)
|
||||
// }
|
||||
// ^^^
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
.onChange(of: member.memberRole) { role in
|
||||
newRole = role
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .blockMemberAlert(mem): return blockMemberAlert(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 .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
planAndConnect(
|
||||
contactLink,
|
||||
showAlert: { alert = .planAndConnectAlert(alert: $0) },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: nil
|
||||
)
|
||||
connectViaAddress(contactLink)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddress(_ contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
|
||||
if let connReqType = connReqType {
|
||||
alert = .connRequestSentAlert(type: connReqType)
|
||||
} else if let connectAlert = connectAlert {
|
||||
alert = .other(alert: connectAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
||||
Button {
|
||||
dismissAllSheets(animated: true)
|
||||
@@ -289,83 +239,45 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func createMemberContactButton() -> some View {
|
||||
Button {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
dismissAllSheets(animated: true)
|
||||
chatModel.chatId = memberContact.id
|
||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||
VStack {
|
||||
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if mem.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
HStack {
|
||||
if mem.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
}
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
if mem.fullName != "" && mem.fullName != mem.displayName {
|
||||
Text(mem.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
let member = groupMember.wrapped
|
||||
return NavigationLink {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
displayName: member.displayName,
|
||||
connectionCode: code,
|
||||
connectionVerified: member.verified,
|
||||
verify: { code in
|
||||
var member = groupMember.wrapped
|
||||
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
member.activeConn?.connectionCode = connCode
|
||||
_ = chatModel.upsertGroupMember(groupInfo, member)
|
||||
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
|
||||
}
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
@@ -379,90 +291,7 @@ struct GroupMemberInfoView: View {
|
||||
systemImage: member.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncMemberConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
Button(role: .destructive) {
|
||||
alert = .blockMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Block member", systemImage: "hand.raised")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func unblockMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button {
|
||||
alert = .unblockMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Unblock member", systemImage: "hand.raised.slash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
@@ -470,7 +299,7 @@ struct GroupMemberInfoView: View {
|
||||
alert = .removeMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +335,7 @@ struct GroupMemberInfoView: View {
|
||||
do {
|
||||
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
|
||||
await MainActor.run {
|
||||
member = updatedMember
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
|
||||
@@ -526,12 +356,8 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
|
||||
dismiss()
|
||||
}
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -545,11 +371,8 @@ struct GroupMemberInfoView: View {
|
||||
private func abortSwitchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
@@ -559,110 +382,13 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncMemberConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Block member?"),
|
||||
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
|
||||
primaryButton: .destructive(Text("Block")) {
|
||||
toggleShowMemberMessages(gInfo, mem, false)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Unblock member?"),
|
||||
message: Text("Messages from \(mem.chatViewName) will be shown!"),
|
||||
primaryButton: .default(Text("Unblock")) {
|
||||
toggleShowMemberMessages(gInfo, mem, true)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) {
|
||||
var memberSettings = member.memberSettings
|
||||
memberSettings.showMessages = showMessages
|
||||
updateMemberSettings(gInfo, member, memberSettings)
|
||||
}
|
||||
|
||||
func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) {
|
||||
Task {
|
||||
do {
|
||||
try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
|
||||
await MainActor.run {
|
||||
var mem = member
|
||||
mem.memberSettings = memberSettings
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, mem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiSetMemberSettings error \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
groupMember: GMember.sampleData
|
||||
member: GroupMember.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||
featureSection(.reactions, $preferences.reactions.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
featureSection(.files, $preferences.files.enable)
|
||||
featureSection(.history, $preferences.history.enable)
|
||||
// TODO uncomment in 5.3
|
||||
// featureSection(.files, $preferences.files.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
@@ -97,6 +97,7 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,6 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum GroupProfileAlert: Identifiable {
|
||||
case saveError(err: String)
|
||||
case invalidName(validName: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .saveError(err): return "saveError \(err)"
|
||||
case let .invalidName(validName): return "invalidName \(validName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupProfileView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -30,7 +18,8 @@ struct GroupProfileView: View {
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var alert: GroupProfileAlert?
|
||||
@State private var showSaveErrorAlert = false
|
||||
@State private var saveGroupError: String? = nil
|
||||
@FocusState private var focusDisplayName
|
||||
|
||||
var body: some View {
|
||||
@@ -58,29 +47,20 @@ struct GroupProfileView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if !validNewProfileName() {
|
||||
Button {
|
||||
alert = .invalidName(validName: mkValidName(groupProfile.displayName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
ZStack(alignment: .leading) {
|
||||
if !validDisplayName(groupProfile.displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
profileNameTextEdit("Group display name", $groupProfile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
}
|
||||
.padding(.bottom)
|
||||
let fullName = groupInfo.groupProfile.fullName
|
||||
if fullName != "" && fullName != groupProfile.displayName {
|
||||
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
|
||||
.padding(.bottom)
|
||||
}
|
||||
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
|
||||
HStack(spacing: 20) {
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Save group profile") { saveProfile() }
|
||||
.disabled(!canUpdateProfile())
|
||||
.disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
|
||||
@@ -103,10 +83,8 @@ struct GroupProfileView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run {
|
||||
showImagePicker = false
|
||||
}
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
@@ -121,39 +99,27 @@ struct GroupProfileView: View {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .saveError(err):
|
||||
return Alert(
|
||||
title: Text("Error saving group profile"),
|
||||
message: Text(err)
|
||||
)
|
||||
case let .invalidName(name):
|
||||
return createInvalidNameAlert(name, $groupProfile.displayName)
|
||||
}
|
||||
.alert(isPresented: $showSaveErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error saving group profile"),
|
||||
message: Text("\(saveGroupError ?? "Unexpected error")")
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { hideKeyboard() }
|
||||
}
|
||||
|
||||
private func canUpdateProfile() -> Bool {
|
||||
groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName()
|
||||
}
|
||||
|
||||
private func validNewProfileName() -> Bool {
|
||||
groupProfile.displayName == groupInfo.groupProfile.displayName
|
||||
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
|
||||
TextField(label, text: name)
|
||||
.padding(.leading, 32)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
@@ -162,8 +128,9 @@ struct GroupProfileView: View {
|
||||
}
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
alert = .saveError(err: err)
|
||||
logger.error("GroupProfile apiUpdateGroup error: \(err)")
|
||||
saveGroupError = err
|
||||
showSaveErrorAlert = true
|
||||
logger.error("UserProfile apiUpdateProfile error: \(err)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,32 +11,29 @@ import SimpleXChat
|
||||
|
||||
struct GroupWelcomeView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
var groupId: Int64
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var groupProfile: GroupProfile
|
||||
@State var welcomeText: String
|
||||
@State private var welcomeText: String = ""
|
||||
@State private var editMode = true
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showSaveDialog = false
|
||||
|
||||
let maxByteCount = 1200
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if groupInfo.canEdit {
|
||||
editorView()
|
||||
.modifier(BackButton {
|
||||
if welcomeTextUnchanged() {
|
||||
if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
|
||||
dismiss()
|
||||
} else {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog(
|
||||
welcomeTextFitsLimit() ? "Save welcome message?" : "Welcome message is too long",
|
||||
isPresented: $showSaveDialog
|
||||
) {
|
||||
if welcomeTextFitsLimit() {
|
||||
Button("Save and update group profile") { save() }
|
||||
.confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
|
||||
Button("Save and update group profile") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
@@ -50,15 +47,15 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
keyboardVisible = true
|
||||
}
|
||||
welcomeText = groupInfo.groupProfile.description ?? ""
|
||||
keyboardVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
private func textPreview() -> some View {
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
|
||||
.frame(minHeight: 130, alignment: .topLeading)
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil)
|
||||
.allowsHitTesting(false)
|
||||
.frame(minHeight: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -78,7 +75,7 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 130, alignment: .topLeading)
|
||||
.frame(height: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
@@ -97,9 +94,6 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
.disabled(welcomeText.isEmpty)
|
||||
copyButton()
|
||||
} footer: {
|
||||
Text(!welcomeTextFitsLimit() ? "Message too large" : "")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -120,15 +114,7 @@ struct GroupWelcomeView: View {
|
||||
Button("Save and update group profile") {
|
||||
save()
|
||||
}
|
||||
.disabled(welcomeTextUnchanged() || !welcomeTextFitsLimit())
|
||||
}
|
||||
|
||||
private func welcomeTextUnchanged() -> Bool {
|
||||
welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil)
|
||||
}
|
||||
|
||||
private func welcomeTextFitsLimit() -> Bool {
|
||||
chatJsonLength(welcomeText) <= maxByteCount
|
||||
.disabled(welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil))
|
||||
}
|
||||
|
||||
private func save() {
|
||||
@@ -138,13 +124,11 @@ struct GroupWelcomeView: View {
|
||||
if welcome?.count == 0 {
|
||||
welcome = nil
|
||||
}
|
||||
groupProfile.description = welcome
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
ChatModel.shared.updateGroup(gInfo)
|
||||
dismiss()
|
||||
}
|
||||
var groupProfileUpdated = groupInfo.groupProfile
|
||||
groupProfileUpdated.description = welcome
|
||||
groupInfo = try await apiUpdateGroup(groupId, groupProfileUpdated)
|
||||
m.updateGroup(groupInfo)
|
||||
welcomeText = welcome ?? ""
|
||||
} catch let error {
|
||||
logger.error("apiUpdateGroup error: \(responseError(error))")
|
||||
}
|
||||
@@ -154,6 +138,6 @@ struct GroupWelcomeView: View {
|
||||
|
||||
struct GroupWelcomeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||
GroupWelcomeView(groupId: 1, groupInfo: Binding.constant(GroupInfo.sampleData))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ struct ScanCodeView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode)
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.border(.gray)
|
||||
Text("Scan security code from your contact's app.")
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ struct VerifyCodeView: View {
|
||||
HStack {
|
||||
NavigationLink {
|
||||
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Scan code")
|
||||
} label: {
|
||||
Label("Scan code", systemImage: "qrcode")
|
||||
|
||||
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var showAddChat = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
@@ -39,12 +39,13 @@ struct ChatHelp: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Tap button ")
|
||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
NewChatButton(showAddChat: $showAddChat)
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
|
||||
Text("**Create group**: to create a new group.")
|
||||
Text("**Create link / QR code** for your contact to use.")
|
||||
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)
|
||||
|
||||
|
||||
@@ -32,104 +32,56 @@ struct ChatListNavLink: View {
|
||||
@State private var showJoinGroupDialog = false
|
||||
@State private var showContactConnectionInfo = false
|
||||
@State private var showInvalidJSON = false
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
contactNavLink(contact)
|
||||
case let .group(groupInfo):
|
||||
groupNavLink(groupInfo)
|
||||
case let .local(noteFolder):
|
||||
noteFolderNavLink(noteFolder)
|
||||
case let .contactRequest(cReq):
|
||||
contactRequestNavLink(cReq)
|
||||
case let .contactConnection(cConn):
|
||||
contactConnectionNavLink(cConn)
|
||||
case let .invalidJSON(json):
|
||||
invalidJSONPreview(json)
|
||||
}
|
||||
}
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
progressByTimeout = inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
contactNavLink(contact)
|
||||
case let .group(groupInfo):
|
||||
groupNavLink(groupInfo)
|
||||
case let .contactRequest(cReq):
|
||||
contactRequestNavLink(cReq)
|
||||
case let .contactConnection(cConn):
|
||||
contactConnectionNavLink(cConn)
|
||||
case let .invalidJSON(json):
|
||||
invalidJSONPreview(json)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||
Group {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
showDeleteContactActionSheet = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.onTapGesture { showConnectContactViaAddressDialog = true }
|
||||
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
|
||||
Button("Use current profile") { connectContactViaAddress_(contact, false) }
|
||||
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
|
||||
}
|
||||
} else {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.ready || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
}
|
||||
let v = NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !contact.ready
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
||||
.cancel()
|
||||
]
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
AlertManager.shared.showAlert(
|
||||
contact.ready
|
||||
? deleteContactAlert(chat.chatInfo)
|
||||
: deletePendingContactAlert(chat, contact)
|
||||
)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
|
||||
if contact.ready {
|
||||
v
|
||||
} else {
|
||||
v.onTapGesture {
|
||||
AlertManager.shared.showAlert(pendingContactAlert(chat, contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +89,7 @@ struct ChatListNavLink: View {
|
||||
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||
ChatPreviewView(chat: chat)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
@@ -148,16 +100,12 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture { showJoinGroupDialog = true }
|
||||
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
|
||||
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
|
||||
inProgress = true
|
||||
joinGroup(groupInfo.groupId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(groupInfo.groupId)
|
||||
}
|
||||
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
|
||||
}
|
||||
.disabled(inProgress)
|
||||
case .memAccepted:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
ChatPreviewView(chat: chat)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
@@ -174,7 +122,7 @@ struct ChatListNavLink: View {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
@@ -197,30 +145,9 @@ 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 {
|
||||
Button {
|
||||
inProgress = true
|
||||
joinGroup(chat.chatInfo.apiId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(chat.chatInfo.apiId)
|
||||
} label: {
|
||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
||||
}
|
||||
@@ -273,15 +200,6 @@ struct ChatListNavLink: View {
|
||||
.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 {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||
@@ -304,15 +222,9 @@ struct ChatListNavLink: View {
|
||||
ContactRequestView(contactRequest: contactRequest, chat: chat)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||
} label: { Label("Accept", systemImage: "checkmark") }
|
||||
.tint(.accentColor)
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||
} label: {
|
||||
Label("Accept incognito", systemImage: "theatermasks")
|
||||
}
|
||||
.tint(.indigo)
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
|
||||
.tint(chatModel.incognito ? .indigo : .accentColor)
|
||||
Button {
|
||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||
} label: {
|
||||
@@ -322,10 +234,9 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
|
||||
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
|
||||
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
|
||||
Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +263,6 @@ struct ChatListNavLink: View {
|
||||
.sheet(isPresented: $showContactConnectionInfo) {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
@@ -360,6 +270,17 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete contact?"),
|
||||
message: Text("Contact and all messages will be deleted - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task { await deleteChat(chat) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete group?"),
|
||||
@@ -386,17 +307,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 {
|
||||
Alert(
|
||||
title: Text("Leave group?"),
|
||||
@@ -469,20 +379,8 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.sheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
||||
if ok {
|
||||
await MainActor.run {
|
||||
chatModel.chatId = contact.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
||||
@@ -511,22 +409,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
||||
)
|
||||
}
|
||||
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return false
|
||||
} else if let contact = contact {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(contact)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
func joinGroup(_ groupId: Int64) {
|
||||
Task {
|
||||
logger.debug("joinGroup")
|
||||
do {
|
||||
@@ -541,9 +424,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
|
||||
await deleteGroup()
|
||||
}
|
||||
await onComplete()
|
||||
} catch let error {
|
||||
await onComplete()
|
||||
let a = getErrorAlert(error, "Error joining group")
|
||||
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
||||
}
|
||||
|
||||
@@ -12,29 +12,16 @@ import SimpleXChat
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
@State private var searchMode = false
|
||||
@FocusState private var searchFocussed
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var showAddChat = false
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NavStackCompat(
|
||||
isActive: Binding(
|
||||
get: { chatModel.chatId != nil },
|
||||
get: { ChatModel.shared.chatId != nil },
|
||||
set: { _ in }
|
||||
),
|
||||
destination: chatView
|
||||
@@ -53,56 +40,27 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(
|
||||
showSettings: $showSettings,
|
||||
showConnectDesktop: $showConnectDesktop,
|
||||
userPickerVisible: $userPickerVisible
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showConnectDesktop) {
|
||||
ConnectDesktopView()
|
||||
UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible)
|
||||
}
|
||||
}
|
||||
|
||||
private var chatListView: some View {
|
||||
VStack {
|
||||
chatList
|
||||
if chatModel.chats.count > 0 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.onAppear() { connectViaUrl() }
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Reconnect servers?"),
|
||||
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
Task {
|
||||
do {
|
||||
try await reconnectAllServers()
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlertMsg(title: "Error", message: "\(responseError(error))")
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
Button {
|
||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
@@ -110,10 +68,28 @@ struct ChatListView: View {
|
||||
} else {
|
||||
showSettings = true
|
||||
}
|
||||
} label: {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
if (chatModel.incognito) {
|
||||
Image(systemName: "theatermasks")
|
||||
.foregroundColor(.indigo)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Chats")
|
||||
.font(.headline)
|
||||
if chatModel.chats.count > 0 {
|
||||
@@ -124,7 +100,7 @@ struct ChatListView: View {
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
case .some(true): NewChatButton(showAddChat: $showAddChat)
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
@@ -144,25 +120,11 @@ struct ChatListView: View {
|
||||
@ViewBuilder private var chatList: some View {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
VStack {
|
||||
List {
|
||||
if !chatModel.chats.isEmpty {
|
||||
ChatListSearchBar(
|
||||
searchMode: $searchMode,
|
||||
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)
|
||||
List {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
@@ -196,9 +158,16 @@ struct ChatListView: View {
|
||||
.padding(.trailing, 12)
|
||||
|
||||
connectButton("Tap to start a new chat") {
|
||||
newChatMenuOption = .newContact
|
||||
showAddChat = true
|
||||
}
|
||||
|
||||
connectButton("or chat with the developers") {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
Spacer()
|
||||
Text("You have no chats")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -228,27 +197,22 @@ struct ChatListView: View {
|
||||
}
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
} else {
|
||||
let s = searchString()
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { chat in
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
return s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .local:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .contactRequest:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn):
|
||||
@@ -257,11 +221,6 @@ struct ChatListView: View {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
|
||||
func filtered(_ chat: Chat) -> Bool {
|
||||
(chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||
@@ -273,121 +232,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 {
|
||||
Button {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -12,13 +12,9 @@ import SimpleXChat
|
||||
struct ChatPreviewView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressByTimeout: Bool
|
||||
@State var deleting: Bool = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return HStack(spacing: 8) {
|
||||
@@ -34,7 +30,7 @@ struct ChatPreviewView: View {
|
||||
HStack(alignment: .top) {
|
||||
chatPreviewTitle()
|
||||
Spacer()
|
||||
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
|
||||
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -45,9 +41,11 @@ struct ChatPreviewView: View {
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
chatMessagePreview(cItem)
|
||||
chatStatusImage()
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
if case .direct = chat.chatInfo {
|
||||
chatStatusImage()
|
||||
.padding(.top, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
@@ -56,34 +54,27 @@ struct ChatPreviewView: View {
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.bottom, -8)
|
||||
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
||||
deleting = contains
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if !contact.active {
|
||||
inactiveIcon()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memLeft: inactiveIcon()
|
||||
case .memRemoved: inactiveIcon()
|
||||
case .memGroupDeleted: inactiveIcon()
|
||||
case .memLeft:
|
||||
groupInactiveIcon()
|
||||
case .memRemoved:
|
||||
groupInactiveIcon()
|
||||
case .memGroupDeleted:
|
||||
groupInactiveIcon()
|
||||
default: EmptyView()
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func inactiveIcon() -> some View {
|
||||
@ViewBuilder private func groupInactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.foregroundColor(.secondary)
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
}
|
||||
|
||||
@@ -91,13 +82,14 @@ struct ChatPreviewView: View {
|
||||
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil)
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case let .group(groupInfo):
|
||||
let v = previewTitle(t)
|
||||
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)
|
||||
default: if deleting { v.foregroundColor(.secondary) } else { v }
|
||||
default: v
|
||||
}
|
||||
default: previewTitle(t)
|
||||
}
|
||||
@@ -114,19 +106,14 @@ struct ChatPreviewView: View {
|
||||
.kerning(-2)
|
||||
}
|
||||
|
||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
||||
private func chatPreviewLayout(_ text: Text) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let t = text
|
||||
text
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
if !showChatPreviews && !draft {
|
||||
t.privacySensitive(true).redacted(reason: .privacy)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
@@ -134,9 +121,9 @@ struct ChatPreviewView: View {
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.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)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
} else if !chat.chatInfo.ntfsEnabled {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.foregroundColor(.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
@@ -154,7 +141,7 @@ struct ChatPreviewView: View {
|
||||
let msg = draft.message
|
||||
return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
|
||||
+ 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 {
|
||||
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
||||
@@ -171,20 +158,9 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
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
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true)
|
||||
|
||||
func attachment() -> String? {
|
||||
switch cItem.content.msgContent {
|
||||
@@ -199,21 +175,14 @@ struct ChatPreviewView: View {
|
||||
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
|
||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||
chatPreviewLayout(messageDraft(draft), draft: true)
|
||||
chatPreviewLayout(messageDraft(draft))
|
||||
} else if let cItem = cItem {
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
chatPreviewInfoText("Tap to Connect")
|
||||
.foregroundColor(.accentColor)
|
||||
} else if !contact.ready && contact.activeConn != nil {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else if contact.active {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
if !contact.ready {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
@@ -229,7 +198,10 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
|
||||
groupInfo.membership.memberIncognito
|
||||
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
||||
: chatPreviewInfoText("you are invited to group")
|
||||
: (chatModel.incognito
|
||||
? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
|
||||
: chatPreviewInfoText("you are invited to group")
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
|
||||
@@ -256,45 +228,23 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if contact.active && contact.activeConn != nil {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
case .group:
|
||||
if progressByTimeout {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: EmptyView()
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
|
||||
if incognito {
|
||||
Image(systemName: "theatermasks")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
func unreadCountText(_ n: Int) -> Text {
|
||||
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
|
||||
}
|
||||
@@ -305,30 +255,30 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: []
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.group,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 78))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ struct ContactConnectionInfo: View {
|
||||
@State var contactConnection: PendingContactConnection
|
||||
@State private var alert: CCInfoAlert?
|
||||
@State private var localAlias = ""
|
||||
@State private var showIncognitoSheet = false
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
|
||||
enum CCInfoAlert: Identifiable {
|
||||
@@ -32,14 +31,19 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let v = List {
|
||||
List {
|
||||
Group {
|
||||
Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection")
|
||||
Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(contactConnectionText(contactConnection))
|
||||
.padding(.bottom, 16)
|
||||
|
||||
if let connReqInv = contactConnection.connReqInv {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -61,16 +65,10 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
if contactConnection.initiated,
|
||||
let connReqInv = contactConnection.connReqInv {
|
||||
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
|
||||
incognitoEnabled()
|
||||
shareLinkButton(connReqInv)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
} else {
|
||||
incognitoEnabled()
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
} footer: {
|
||||
sharedProfileInfo(contactConnection.incognito)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -82,14 +80,6 @@ struct ContactConnectionInfo: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if #available(iOS 16, *) {
|
||||
v
|
||||
} else {
|
||||
// navigationBarHidden is added conditionally,
|
||||
// because the view jumps in iOS 17 if this is added,
|
||||
// and on iOS 16+ it is hidden without it.
|
||||
v.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { _alert in
|
||||
switch _alert {
|
||||
@@ -119,7 +109,7 @@ struct ContactConnectionInfo: View {
|
||||
if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
m.updateContactConnection(conn)
|
||||
ChatModel.shared.updateContactConnection(conn)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -138,52 +128,6 @@ struct ContactConnectionInfo: View {
|
||||
)
|
||||
: "You will be connected when your contact's device is online, please wait or check later!"
|
||||
}
|
||||
|
||||
@ViewBuilder private func incognitoEnabled() -> some View {
|
||||
if contactConnection.incognito {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: "theatermasks.fill")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(Color.indigo)
|
||||
.font(.system(size: 14))
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -58,14 +58,10 @@ struct ContactConnectionView: View {
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text(contactConnection.description)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
incognitoIcon(contactConnection.incognito)
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
Text(contactConnection.description)
|
||||
.frame(alignment: .topLeading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct ContactRequestView: View {
|
||||
Text(contactRequest.chatViewName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(chatModel.incognito ? .indigo : .accentColor)
|
||||
.padding(.leading, 8)
|
||||
.frame(alignment: .topLeading)
|
||||
Spacer()
|
||||
|
||||
@@ -13,7 +13,6 @@ struct UserPicker: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showConnectDesktop: Bool
|
||||
@Binding var userPickerVisible: Bool
|
||||
@State var scrollViewContentSize: CGSize = .zero
|
||||
@State var disableScrolling: Bool = true
|
||||
@@ -63,13 +62,6 @@ struct UserPicker: View {
|
||||
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
|
||||
.frame(maxHeight: scrollViewContentSize.height)
|
||||
|
||||
menuButton("Use from desktop", icon: "desktopcomputer") {
|
||||
showConnectDesktop = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
menuButton("Settings", icon: "gearshape") {
|
||||
showSettings = true
|
||||
withAnimation {
|
||||
@@ -93,7 +85,7 @@ struct UserPicker: View {
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
} catch let error {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
logger.error("Error updating users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +144,7 @@ struct UserPicker: View {
|
||||
.overlay(DetermineWidth())
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(.secondary)
|
||||
// .frame(width: 24, alignment: .center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 22)
|
||||
@@ -179,7 +170,6 @@ struct UserPicker_Previews: PreviewProvider {
|
||||
m.users = [UserInfo.sampleData, UserInfo.sampleData]
|
||||
return UserPicker(
|
||||
showSettings: Binding.constant(false),
|
||||
showConnectDesktop: Binding.constant(false),
|
||||
userPickerVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(m)
|
||||
|
||||
@@ -149,7 +149,7 @@ struct DatabaseErrorView: View {
|
||||
private func runChatSync(confirmMigrations: MigrationConfirmation? = nil) {
|
||||
do {
|
||||
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 {
|
||||
status = s
|
||||
let am = AlertManager.shared
|
||||
|
||||
@@ -415,7 +415,7 @@ struct DatabaseView: View {
|
||||
do {
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
appStateGroupDefault.set(.active)
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
@@ -427,7 +427,7 @@ struct DatabaseView: View {
|
||||
m.chatRunning = true
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
appStateGroupDefault.set(.active)
|
||||
} catch let error {
|
||||
runChat = false
|
||||
alert = .error(title: "Error starting chat", error: responseError(error))
|
||||
@@ -477,14 +477,13 @@ func stopChatAsync() async throws {
|
||||
try await apiStopChat()
|
||||
ChatReceiver.shared.stop()
|
||||
await MainActor.run { ChatModel.shared.chatRunning = false }
|
||||
AppChatState.shared.set(.stopped)
|
||||
appStateGroupDefault.set(.stopped)
|
||||
}
|
||||
|
||||
func deleteChatAsync() async throws {
|
||||
try await apiDeleteStorage()
|
||||
_ = kcDatabasePassword.remove()
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
deleteAppDatabaseAndFiles()
|
||||
}
|
||||
|
||||
struct DatabaseView_Previews: PreviewProvider {
|
||||
|
||||
@@ -65,7 +65,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .exporting:
|
||||
center {
|
||||
ProgressView(value: 0.33)
|
||||
Text("Exporting database archive…")
|
||||
Text("Exporting database archive...")
|
||||
}
|
||||
migrationProgress()
|
||||
case .export_error:
|
||||
@@ -82,7 +82,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .migrating:
|
||||
center {
|
||||
ProgressView(value: 0.67)
|
||||
Text("Migrating database archive…")
|
||||
Text("Migrating database archive...")
|
||||
}
|
||||
migrationProgress()
|
||||
case .migration_error:
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatInfoImage: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
@@ -19,16 +18,13 @@ struct ChatInfoImage: View {
|
||||
switch chat.chatInfo {
|
||||
case .direct: iconName = "person.crop.circle.fill"
|
||||
case .group: iconName = "person.2.circle.fill"
|
||||
case .local: iconName = "folder.circle.fill"
|
||||
case .contactRequest: iconName = "person.crop.circle.fill"
|
||||
default: iconName = "circle.fill"
|
||||
}
|
||||
let notesColor = colorScheme == .light ? notesChatColorLight : notesChatColorDark
|
||||
let iconColor = if case .local = chat.chatInfo { notesColor } else { color }
|
||||
return ProfileImage(
|
||||
imageStr: chat.chatInfo.image,
|
||||
iconName: iconName,
|
||||
color: iconColor
|
||||
color: color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,130 +13,112 @@ import SimpleXChat
|
||||
|
||||
struct LibraryImagePicker: View {
|
||||
@Binding var image: UIImage?
|
||||
var didFinishPicking: (_ didSelectImage: Bool) async -> Void
|
||||
@State var mediaAdded = false
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
@State var images: [UploadContent] = []
|
||||
|
||||
var body: some View {
|
||||
LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
}
|
||||
|
||||
private func addMedia(_ content: UploadContent) async {
|
||||
if mediaAdded { return }
|
||||
await MainActor.run {
|
||||
mediaAdded = true
|
||||
image = content.uiImage
|
||||
}
|
||||
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
.onChange(of: images) { _ in
|
||||
if let img = images.first {
|
||||
image = img.uiImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = PHPickerViewController
|
||||
var addMedia: (_ content: UploadContent) async -> Void
|
||||
@Binding var media: [UploadContent]
|
||||
var selectionLimit: Int
|
||||
var finishedPreprocessing: () -> Void = {}
|
||||
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
|
||||
class Coordinator: PHPickerViewControllerDelegate {
|
||||
let parent: LibraryMediaListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
|
||||
var media: [UploadContent] = []
|
||||
var mediaCount: Int = 0
|
||||
|
||||
init(_ parent: LibraryMediaListPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
Task {
|
||||
await parent.didFinishPicking(!results.isEmpty)
|
||||
if results.isEmpty { return }
|
||||
for r in results {
|
||||
await loadItem(r.itemProvider)
|
||||
}
|
||||
parent.finishedPreprocessing()
|
||||
parent.didFinishPicking(!results.isEmpty)
|
||||
guard !results.isEmpty else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItem(_ p: NSItemProvider) async {
|
||||
logger.debug("LibraryMediaListPicker result")
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
if let video = await loadVideo(p) {
|
||||
await self.parent.addMedia(video)
|
||||
logger.debug("LibraryMediaListPicker: added video")
|
||||
}
|
||||
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
if let img = await loadImageData(p) {
|
||||
await self.parent.addMedia(img)
|
||||
logger.debug("LibraryMediaListPicker: added image")
|
||||
}
|
||||
} else if p.canLoadObject(ofClass: UIImage.self) {
|
||||
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)
|
||||
parent.media = []
|
||||
media = []
|
||||
mediaCount = results.count
|
||||
for result in results {
|
||||
logger.log("LibraryMediaListPicker result")
|
||||
let p = result.itemProvider
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let url = url {
|
||||
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
|
||||
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
|
||||
ChatModel.shared.filesToDelete.insert(tempUrl)
|
||||
self.loadVideo(url: tempUrl, error: error)
|
||||
}
|
||||
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) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in
|
||||
if let err = err {
|
||||
logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)")
|
||||
completion(nil)
|
||||
} else {
|
||||
completion(url)
|
||||
func loadImage(object: Any?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
} else if let image = object as? UIImage {
|
||||
media.append(.simpleImage(image: image))
|
||||
logger.log("LibraryMediaListPicker: added image")
|
||||
} 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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +133,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
config.selectionLimit = selectionLimit
|
||||
config.selection = .ordered
|
||||
config.preferredAssetRepresentationMode = .current
|
||||
//config.preferredAssetRepresentationMode = .current
|
||||
let controller = PHPickerViewController(configuration: config)
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//
|
||||
// Keyboard.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// KeyboardPadding.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func keyboardPadding() -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
GeometryReader { g in
|
||||
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct LocalAuthRequest {
|
||||
}
|
||||
|
||||
func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct: Bool = false, completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("DEBUGGING: authenticate")
|
||||
logger.debug("authenticate")
|
||||
switch privacyLocalAuthModeDefault.get() {
|
||||
case .system: systemAuthenticate(reason, completed)
|
||||
case .passcode:
|
||||
@@ -58,24 +58,21 @@ func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct
|
||||
}
|
||||
|
||||
func systemAuthenticate(_ reason: String, _ completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("DEBUGGING: systemAuthenticate")
|
||||
let laContext = LAContext()
|
||||
var authAvailabilityError: NSError?
|
||||
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
|
||||
logger.debug("DEBUGGING: systemAuthenticate: canEvaluatePolicy callback")
|
||||
laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
||||
logger.debug("DEBUGGING: systemAuthenticate evaluatePolicy callback")
|
||||
DispatchQueue.main.async {
|
||||
if success {
|
||||
completed(LAResult.success)
|
||||
} else {
|
||||
logger.error("DEBUGGING: systemAuthenticate authentication error: \(authError.debugDescription)")
|
||||
logger.error("authentication error: \(authError.debugDescription)")
|
||||
completed(LAResult.failed(authError: authError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("DEBUGGING: authentication availability error: \(authAvailabilityError.debugDescription)")
|
||||
logger.error("authentication availability error: \(authAvailabilityError.debugDescription)")
|
||||
completed(LAResult.unavailable(authError: authAvailabilityError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
func showShareSheet(items: [Any]) {
|
||||
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
||||
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let completed = completed {
|
||||
let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
|
||||
activityViewController.completionWithItemsHandler = handler
|
||||
}
|
||||
presentedViewController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import Combine
|
||||
|
||||
struct VideoPlayerView: UIViewRepresentable {
|
||||
|
||||
@@ -38,14 +37,6 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
var played = false
|
||||
context.coordinator.publisher = player.publisher(for: \.timeControlStatus).sink { status in
|
||||
if played || status == .playing {
|
||||
AppDelegate.keepScreenOn(status == .playing)
|
||||
AudioPlayer.changeAudioSession(status == .playing)
|
||||
}
|
||||
played = status == .playing
|
||||
}
|
||||
return controller.view
|
||||
}
|
||||
|
||||
@@ -59,13 +50,12 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
class Coordinator: NSObject {
|
||||
var controller: AVPlayerViewController?
|
||||
var timeObserver: Any? = nil
|
||||
var publisher: AnyCancellable? = nil
|
||||
|
||||
deinit {
|
||||
print("deinit coordinator of VideoPlayer")
|
||||
if let timeObserver = timeObserver {
|
||||
NotificationCenter.default.removeObserver(timeObserver)
|
||||
}
|
||||
publisher?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
var authRequest: LocalAuthRequest
|
||||
@State private var password = ""
|
||||
@State private var allowToReact = true
|
||||
|
||||
var body: some View {
|
||||
PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit",
|
||||
buttonsEnabled: $allowToReact) {
|
||||
PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit") {
|
||||
if let sdPassword = kcSelfDestructPassword.get(), authRequest.selfDestruct && password == sdPassword {
|
||||
allowToReact = false
|
||||
deleteStorageAndRestart(sdPassword) { r in
|
||||
m.laRequest = nil
|
||||
authRequest.completed(r)
|
||||
}
|
||||
return
|
||||
}
|
||||
let r: LAResult
|
||||
if password == authRequest.password {
|
||||
if authRequest.selfDestruct && kcSelfDestructPassword.get() != nil && !m.chatInitialized {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
r = .success
|
||||
} else {
|
||||
r = .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
|
||||
}
|
||||
let r: LAResult = password == authRequest.password
|
||||
? .success
|
||||
: .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
|
||||
m.laRequest = nil
|
||||
authRequest.completed(r)
|
||||
} cancel: {
|
||||
@@ -46,27 +37,8 @@ struct LocalAuthView: View {
|
||||
private func deleteStorageAndRestart(_ password: String, completed: @escaping (LAResult) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
/** Waiting until [initializeChat] finishes */
|
||||
while (m.ctrlInitInProgress) {
|
||||
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 = []
|
||||
try await stopChatAsync()
|
||||
try await deleteChatAsync()
|
||||
_ = kcAppPassword.set(password)
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
await NtfManager.shared.removeAllNotifications()
|
||||
@@ -80,8 +52,8 @@ struct LocalAuthView: View {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
if m.currentUser != nil || !m.chatInitialized { return }
|
||||
appStateGroupDefault.set(.active)
|
||||
if m.currentUser != nil { return }
|
||||
var profile: Profile? = nil
|
||||
if let displayName = displayName, displayName != "" {
|
||||
profile = Profile(displayName: displayName, fullName: "")
|
||||
|
||||
@@ -14,8 +14,6 @@ struct PasscodeView: View {
|
||||
var reason: String? = nil
|
||||
var submitLabel: LocalizedStringKey
|
||||
var submitEnabled: ((String) -> Bool)?
|
||||
@Binding var buttonsEnabled: Bool
|
||||
|
||||
var submit: () -> Void
|
||||
var cancel: () -> Void
|
||||
|
||||
@@ -72,11 +70,11 @@ struct PasscodeView: View {
|
||||
@ViewBuilder private func buttonsView() -> some View {
|
||||
Button(action: cancel) {
|
||||
Label("Cancel", systemImage: "multiply")
|
||||
}.disabled(!buttonsEnabled)
|
||||
}
|
||||
Button(action: submit) {
|
||||
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",
|
||||
reason: "Unlock app",
|
||||
submitLabel: "Submit",
|
||||
buttonsEnabled: Binding.constant(true),
|
||||
submit: {},
|
||||
cancel: {}
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct SetAppPasscodeView: View {
|
||||
var passcodeKeychain: KeyChainItem = kcAppPassword
|
||||
var prohibitedPasscodeKeychain: KeyChainItem = kcSelfDestructPassword
|
||||
var title: LocalizedStringKey = "New Passcode"
|
||||
var reason: String?
|
||||
var submit: () -> Void
|
||||
@@ -42,10 +41,7 @@ struct SetAppPasscodeView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPasswordView(title: title,
|
||||
submitLabel: "Save",
|
||||
// Do not allow to set app passcode == selfDestruct passcode
|
||||
submitEnabled: { pwd in pwd != prohibitedPasscodeKeychain.get() }) {
|
||||
setPasswordView(title: title, submitLabel: "Save") {
|
||||
enteredPassword = passcode
|
||||
passcode = ""
|
||||
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 {
|
||||
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()
|
||||
cancel()
|
||||
}
|
||||
|
||||
@@ -9,20 +9,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddContactLearnMore: View {
|
||||
var showTitle: Bool
|
||||
|
||||
var body: some View {
|
||||
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) {
|
||||
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.")
|
||||
@@ -35,6 +23,6 @@ struct AddContactLearnMore: View {
|
||||
|
||||
struct AddContactLearnMore_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactLearnMore(showTitle: true)
|
||||
AddContactLearnMore()
|
||||
}
|
||||
}
|
||||
|
||||
90
apps/ios/Shared/Views/NewChat/AddContactView.swift
Normal file
90
apps/ios/Shared/Views/NewChat/AddContactView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// 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
|
||||
var contactConnection: PendingContactConnection? = nil
|
||||
var connReqInvitation: String
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section("1-time link") {
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
}
|
||||
}
|
||||
.onAppear { chatModel.connReqInv = connReqInvitation }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
|
||||
if connReqInvitation != "" {
|
||||
QRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
shareLinkButton(connReqInvitation)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
|
||||
private func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [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 OneTimeLinkProfileText: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
var contactConnection: PendingContactConnection? = nil
|
||||
var connReqInvitation: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if (contactConnection?.incognito ?? chatModel.incognito) {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo)
|
||||
Text("A random profile will be sent to your contact")
|
||||
} else {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary)
|
||||
Text("Your chat profile will be sent to your contact")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactView(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")
|
||||
}
|
||||
}
|
||||
@@ -12,109 +12,116 @@ import SimpleXChat
|
||||
struct AddGroupView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@State private var chat: Chat?
|
||||
@State private var groupInfo: GroupInfo?
|
||||
@State private var profile = GroupProfile(displayName: "", fullName: "")
|
||||
@FocusState private var focusDisplayName
|
||||
@FocusState private var focusFullName
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var showInvalidNameAlert = false
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
|
||||
var body: some View {
|
||||
if let chat = chat, let groupInfo = groupInfo {
|
||||
if !groupInfo.membership.memberIncognito {
|
||||
AddGroupMembersViewCommon(
|
||||
chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: true
|
||||
) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
AddGroupMembersViewCommon(
|
||||
chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createGroupView().keyboardPadding()
|
||||
createGroupView()
|
||||
}
|
||||
}
|
||||
|
||||
func createGroupView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
Text("Create secret group")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 24)
|
||||
.onTapGesture(perform: hideKeyboard)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Create secret group")
|
||||
.font(.largeTitle)
|
||||
.padding(.vertical, 4)
|
||||
Text("The group is fully decentralized – it is visible only to the members.")
|
||||
.padding(.bottom, 4)
|
||||
if (m.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to group members").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.frame(maxWidth: 128, maxHeight: 128)
|
||||
if profile.image != nil {
|
||||
Button {
|
||||
profile.image = nil
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12)
|
||||
}
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
profileImageView(profile.image)
|
||||
if profile.image != nil {
|
||||
Button {
|
||||
profile.image = nil
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editImageButton { showChooseSource = true }
|
||||
.buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
editImageButton { showChooseSource = true }
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Section {
|
||||
groupNameTextField()
|
||||
Button(action: createGroup) {
|
||||
settingsRow("checkmark", color: .accentColor) { Text("Create group") }
|
||||
ZStack(alignment: .topLeading) {
|
||||
if !validDisplayName(profile.displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sharedGroupProfileInfo(incognitoDefault)
|
||||
Text("Fully decentralized – visible only to members.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onTapGesture(perform: hideKeyboard)
|
||||
textField("Group display name", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
.submitLabel(.next)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { focusFullName = true }
|
||||
else { focusDisplayName = true }
|
||||
}
|
||||
}
|
||||
textField("Group full name (optional)", text: $profile.fullName)
|
||||
.focused($focusFullName)
|
||||
.submitLabel(.go)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createGroup() }
|
||||
else { focusFullName = true }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createGroup()
|
||||
} label: {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
Button("Take picture") {
|
||||
showTakePhoto = true
|
||||
@@ -130,15 +137,10 @@ struct AddGroupView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run {
|
||||
showImagePicker = false
|
||||
}
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showInvalidNameAlert) {
|
||||
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
if let image = image {
|
||||
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
@@ -146,53 +148,26 @@ struct AddGroupView: View {
|
||||
profile.image = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupNameTextField() -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
if name != mkValidName(name) {
|
||||
Button {
|
||||
showInvalidNameAlert = true
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "pencil").foregroundColor(.secondary)
|
||||
}
|
||||
textField("Enter group name…", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
.submitLabel(.continue)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createGroup() }
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { hideKeyboard() }
|
||||
}
|
||||
|
||||
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
|
||||
TextField(placeholder, text: text)
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
|
||||
func sharedGroupProfileInfo(_ 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."
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.leading, 28)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
func createGroup() {
|
||||
hideKeyboard()
|
||||
do {
|
||||
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(profile)
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||
@@ -213,8 +188,7 @@ struct AddGroupView: View {
|
||||
}
|
||||
|
||||
func canCreateProfile() -> Bool {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
return name != "" && validDisplayName(name)
|
||||
profile.displayName != "" && validDisplayName(profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
apps/ios/Shared/Views/NewChat/ConnectViaLinkView.swift
Normal file
42
apps/ios/Shared/Views/NewChat/ConnectViaLinkView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
89
apps/ios/Shared/Views/NewChat/CreateLinkView.swift
Normal file
89
apps/ios/Shared/Views/NewChat/CreateLinkView.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// CreateLinkView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/09/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
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 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(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 == "" && !creatingConnReq {
|
||||
createInvitation()
|
||||
}
|
||||
}
|
||||
.onAppear { m.connReqInv = connReqInvitation }
|
||||
.onDisappear { m.connReqInv = nil }
|
||||
.navigationTitle(selection.title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
|
||||
private func createInvitation() {
|
||||
creatingConnReq = true
|
||||
Task {
|
||||
let connReq = await apiAddContact()
|
||||
await MainActor.run {
|
||||
if let connReq = connReq {
|
||||
connReqInvitation = connReq
|
||||
m.connReqInv = connReq
|
||||
} else {
|
||||
creatingConnReq = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateLinkView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreateLinkView(selection: CreateLinkTab.oneTime)
|
||||
}
|
||||
}
|
||||
127
apps/ios/Shared/Views/NewChat/NewChatButton.swift
Normal file
127
apps/ios/Shared/Views/NewChat/NewChatButton.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// 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)
|
||||
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):
|
||||
CreateLinkView(selection: .oneTime, connReqInvitation: link)
|
||||
case .connectViaLink: ConnectViaLinkView()
|
||||
case .createGroup: AddGroupView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addContactAction() {
|
||||
Task {
|
||||
if let connReq = await apiAddContact() {
|
||||
actionSheet = .createLink(link: connReq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
case contact
|
||||
case invitation
|
||||
}
|
||||
|
||||
func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
|
||||
Task {
|
||||
if let connReqType = await apiConnect(connReq: connectionLink) {
|
||||
DispatchQueue.main.async {
|
||||
dismiss?()
|
||||
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
dismiss?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CReqClientData: Decodable {
|
||||
var type: String
|
||||
var groupLinkId: String?
|
||||
}
|
||||
|
||||
func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? {
|
||||
if let hashIndex = connectionLink.firstIndex(of: "#"),
|
||||
let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])),
|
||||
let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false),
|
||||
let data = components.queryItems?.first(where: { $0.name == "data" })?.value,
|
||||
let d = data.data(using: .utf8),
|
||||
let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) {
|
||||
return crData
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
|
||||
return crData.type == "group" && crData.groupLinkId != nil
|
||||
}
|
||||
|
||||
func groupLinkAlert(_ connectionLink: String) -> Alert {
|
||||
return Alert(
|
||||
title: Text("Connect via group link?"),
|
||||
message: Text("You will join a group this link refers to and connect to its group members."),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
connectViaLink(connectionLink)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
return mkAlert(
|
||||
title: "Connection request sent!",
|
||||
message: type == .contact
|
||||
? "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
: "You will be connected when your contact's device is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
99
apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
Normal file
99
apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// PasteToConnectView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Ian Davies on 22/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PasteToConnectView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var connectionLink: String = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Connect via link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
Text("Paste the link you received into the box below to connect with your contact.")
|
||||
.padding(.bottom, 4)
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
TextEditor(text: $connectionLink)
|
||||
.onSubmit(connect)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.allowsTightening(false)
|
||||
.frame(height: 180)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
if connectionLink == "" {
|
||||
Button {
|
||||
connectionLink = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Label("Paste", systemImage: "doc.plaintext")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
connectionLink = ""
|
||||
} label: {
|
||||
Label("Clear", systemImage: "multiply")
|
||||
}
|
||||
|
||||
}
|
||||
Spacer()
|
||||
Button(action: connect, label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
})
|
||||
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
|
||||
}
|
||||
.frame(height: 48)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
let link = connectionLink.trimmingCharacters(in: .whitespaces)
|
||||
if let crData = parseLinkQueryData(link),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(link))
|
||||
} else {
|
||||
connectViaLink(link, dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PasteToConnectView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PasteToConnectView()
|
||||
}
|
||||
}
|
||||
@@ -11,39 +11,29 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct MutableQRCode: View {
|
||||
@Binding var uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
@State private var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
|
||||
.id("simplex-qrcode-view-for-\(uri)")
|
||||
ZStack {
|
||||
if let image = image {
|
||||
qrCodeImage(image)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
image = generateImage(uri)
|
||||
}
|
||||
.onChange(of: uri) { _ in
|
||||
image = generateImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXLinkQRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
var onShare: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare)
|
||||
}
|
||||
}
|
||||
|
||||
func simplexChatLink(_ uri: String) -> String {
|
||||
uri.starts(with: "simplex:/")
|
||||
? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
|
||||
: uri
|
||||
}
|
||||
|
||||
struct QRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
var onShare: (() -> Void)? = nil
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var makeScreenshotFunc: () -> Void = {}
|
||||
@State private var makeScreenshotBinding: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -64,20 +54,18 @@ struct QRCode: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
makeScreenshotBinding = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
}
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: makeScreenshotFunc)
|
||||
.onTapGesture(perform: makeScreenshotBinding)
|
||||
.onAppear {
|
||||
image = image ?? generateImage(uri, tintColor: tintColor)
|
||||
image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +77,13 @@ private func qrCodeImage(_ image: UIImage) -> some View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
|
||||
private func generateImage(_ uri: String) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(uri.utf8)
|
||||
if let outputImage = filter.outputImage,
|
||||
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
|
||||
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
73
apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
Normal file
73
apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// ConnectContactView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 29/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CodeScanner
|
||||
|
||||
struct ScanToConnectView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Scan QR code")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("A random profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
ZStack {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.border(.gray)
|
||||
}
|
||||
.padding(.bottom)
|
||||
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if let crData = parseLinkQueryData(r.string),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(r.string))
|
||||
} else {
|
||||
Task { connectViaLink(r.string, dismiss) }
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanToConnectView()
|
||||
}
|
||||
}
|
||||
@@ -9,260 +9,174 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum UserProfileAlert: Identifiable {
|
||||
case duplicateUserError
|
||||
case invalidDisplayNameError
|
||||
case createUserError(error: LocalizedStringKey)
|
||||
case invalidNameError(validName: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .duplicateUserError: return "duplicateUserError"
|
||||
case .invalidDisplayNameError: return "invalidDisplayNameError"
|
||||
case .createUserError: return "createUserError"
|
||||
case let .invalidNameError(validName): return "invalidNameError \(validName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateProfile: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@State private var alert: UserProfileAlert?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
Button {
|
||||
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
|
||||
} label: {
|
||||
Label("Create profile", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your profile")
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.onTapGesture {
|
||||
alert = .invalidNameError(validName: validName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 20)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
Text("The profile is only shared with your contacts.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create your profile")
|
||||
.alert(item: $alert) { a in userProfileAlert(a, $displayName) }
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateFirstProfile: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@State private var fullName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@FocusState private var focusFullName
|
||||
@State private var alert: CreateProfileAlert?
|
||||
|
||||
private enum CreateProfileAlert: Identifiable {
|
||||
case duplicateUserError
|
||||
case createUserError(error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .duplicateUserError: return "duplicateUserError"
|
||||
case .createUserError: return "createUserError"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.foregroundColor(.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom, 4)
|
||||
.frame(maxWidth: .infinity)
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.padding(.bottom, 4)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.padding(.bottom)
|
||||
ZStack(alignment: .topLeading) {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
if !validDisplayName(displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
textField("Display name", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
.submitLabel(.next)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { focusFullName = true }
|
||||
else { focusDisplayName = true }
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
textField("Full name (optional)", text: $fullName)
|
||||
.focused($focusFullName)
|
||||
.submitLabel(.go)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createProfile() }
|
||||
else { focusFullName = true }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
onboardingButtons()
|
||||
|
||||
HStack {
|
||||
if m.users.isEmpty {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
createProfile()
|
||||
} label: {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case .duplicateUserError: return duplicateUserAlert
|
||||
case let .createUserError(err): return creatUserErrorAlert(err)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
|
||||
TextField(placeholder, text: text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.leading, 28)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName,
|
||||
fullName: fullName
|
||||
)
|
||||
do {
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
if m.users.isEmpty {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
alert = .duplicateUserError
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
alert = .createUserError(error: err)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func showAlert(_ alert: UserProfileAlert) {
|
||||
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
|
||||
func canCreateProfile() -> Bool {
|
||||
displayName != "" && validDisplayName(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
if m.users.isEmpty {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
showAlert(.invalidDisplayNameError)
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
}
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
private var duplicateUserAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Duplicate display name!"),
|
||||
message: Text("You already have a chat profile with the same display name. Please choose another name.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func canCreateProfile(_ displayName: String) -> Bool {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
return name != "" && mkValidName(name) == name
|
||||
}
|
||||
|
||||
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
|
||||
switch alert {
|
||||
case .duplicateUserError: return duplicateUserAlert
|
||||
case .invalidDisplayNameError: return invalidDisplayNameAlert
|
||||
case let .createUserError(err): return creatUserErrorAlert(err)
|
||||
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
|
||||
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
|
||||
Alert(
|
||||
title: Text("Error creating profile!"),
|
||||
message: Text(err)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var duplicateUserAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Duplicate display name!"),
|
||||
message: Text("You already have a chat profile with the same display name. Please choose another name.")
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
Alert(
|
||||
title: Text("Error creating profile!"),
|
||||
message: Text(err)
|
||||
)
|
||||
}
|
||||
|
||||
func createInvalidNameAlert(_ name: String, _ displayName: Binding<String>) -> Alert {
|
||||
name == ""
|
||||
? Alert(title: Text("Invalid name!"))
|
||||
: Alert(
|
||||
title: Text("Invalid name!"),
|
||||
message: Text("Correct name to \(name)?"),
|
||||
primaryButton: .default(
|
||||
Text("Ok"),
|
||||
action: { displayName.wrappedValue = name }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func validDisplayName(_ name: String) -> Bool {
|
||||
mkValidName(name.trimmingCharacters(in: .whitespaces)) == name
|
||||
}
|
||||
|
||||
func mkValidName(_ s: String) -> String {
|
||||
var c = s.cString(using: .utf8)!
|
||||
return fromCString(chat_valid_name(&c)!)
|
||||
name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#"
|
||||
}
|
||||
|
||||
struct CreateProfile_Previews: PreviewProvider {
|
||||
|
||||
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
|
||||
Spacer()
|
||||
|
||||
if let userAddress = m.userAddress {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
QRCode(uri: userAddress.connReqContact)
|
||||
.frame(maxHeight: g.size.width)
|
||||
shareQRCodeButton(userAddress)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -81,6 +81,11 @@ struct CreateSimpleXAddress: View {
|
||||
DispatchQueue.main.async {
|
||||
m.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
}
|
||||
if let u = try await apiSetProfileAddress(on: true) {
|
||||
DispatchQueue.main.async {
|
||||
m.updateUser(u)
|
||||
}
|
||||
}
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} catch let error {
|
||||
logger.error("CreateSimpleXAddress create address: \(responseError(error))")
|
||||
@@ -95,7 +100,7 @@ struct CreateSimpleXAddress: View {
|
||||
} label: {
|
||||
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)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
@@ -121,7 +126,7 @@ struct CreateSimpleXAddress: View {
|
||||
|
||||
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
|
||||
showShareSheet(items: [userAddress.connReqContact])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
@@ -189,7 +194,7 @@ struct SendAddressMailView: View {
|
||||
let messageBody = String(format: NSLocalizedString("""
|
||||
<p>Hi!</p>
|
||||
<p><a href="%@">Connect to me via SimpleX Chat</a></p>
|
||||
""", comment: "email text"), simplexChatLink(userAddress.connReqContact))
|
||||
""", comment: "email text"), userAddress.connReqContact)
|
||||
MailView(
|
||||
isShowing: self.$showMailView,
|
||||
result: $mailViewResult,
|
||||
|
||||
@@ -14,7 +14,7 @@ struct OnboardingView: View {
|
||||
var body: some View {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
|
||||
case .step2_CreateProfile: CreateFirstProfile()
|
||||
case .step2_CreateProfile: CreateProfile()
|
||||
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
|
||||
case .step4_SetNotificationsMode: SetNotificationsMode()
|
||||
case .onboardingComplete: EmptyView()
|
||||
|
||||
@@ -220,130 +220,6 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "checkmark",
|
||||
title: "Message delivery receipts!",
|
||||
description: "The second tick we missed! ✅"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "star",
|
||||
title: "Find chats faster",
|
||||
description: "Filter unread and favorite chats."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
title: "Keep your connections",
|
||||
description: "Fix encryption after restoring backups."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
title: "Make one message disappear",
|
||||
description: "Even when disabled in the conversation."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "desktopcomputer",
|
||||
title: "New desktop app!",
|
||||
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Encrypt stored files & media",
|
||||
description: "App encrypts new local files (except videos)."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "magnifyingglass",
|
||||
title: "Discover and join groups",
|
||||
description: "- connect to [directory service](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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "theatermasks",
|
||||
title: "Simplified incognito mode",
|
||||
description: "Toggle incognito when connecting."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "character",
|
||||
title: "\(4) new interface languages",
|
||||
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
),
|
||||
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)!"
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -414,15 +290,12 @@ struct WhatsNewView: View {
|
||||
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: icon).foregroundColor(.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
Text(description)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
//
|
||||
// ConnectDesktopView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 13/10/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
|
||||
struct ConnectDesktopView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var viaSettings = false
|
||||
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
|
||||
@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_AUTO) private var connectRemoteViaMulticastAuto = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var sessionAddress: String = ""
|
||||
@State private var remoteCtrls: [RemoteCtrlInfo] = []
|
||||
@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 {
|
||||
case unlinkDesktop(rc: RemoteCtrlInfo)
|
||||
case disconnectDesktop(action: UserDisconnectAction)
|
||||
case badInvitationError
|
||||
case badVersionError(version: String?)
|
||||
case desktopDisconnectedError
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .unlinkDesktop(rc): "unlinkDesktop \(rc.remoteCtrlId)"
|
||||
case let .disconnectDesktop(action): "disconnectDecktop \(action)"
|
||||
case .badInvitationError: "badInvitationError"
|
||||
case let .badVersionError(v): "badVersionError \(v ?? "")"
|
||||
case .desktopDisconnectedError: "desktopDisconnectedError"
|
||||
case let .error(title, _): "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum UserDisconnectAction: String {
|
||||
case back
|
||||
case dismiss // TODO dismiss settings after confirmation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
.modifier(BackButton(label: "Back") {
|
||||
if m.activeRemoteCtrl {
|
||||
alert = .disconnectDesktop(action: .back)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
NavigationView {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewBody: some View {
|
||||
Group {
|
||||
let discovery = m.remoteCtrlSession?.discovery
|
||||
if discovery == true || (discovery == nil && !showConnectScreen) {
|
||||
searchingDesktopView()
|
||||
} else if let session = m.remoteCtrlSession {
|
||||
switch session.sessionState {
|
||||
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 .pendingConfirmation(rc_, sessCode):
|
||||
if confirmRemoteSessions || rc_ == nil {
|
||||
verifySessionView(session, rc_, sessCode)
|
||||
} else {
|
||||
connectingDesktopView(session, rc_).onAppear {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
connectDesktopView(showScanner: false)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setDeviceName(deviceName)
|
||||
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 {
|
||||
if m.remoteCtrlSession != nil {
|
||||
showConnectScreen = false
|
||||
disconnectDesktop()
|
||||
}
|
||||
}
|
||||
.onChange(of: deviceName) {
|
||||
setDeviceName($0)
|
||||
}
|
||||
.onChange(of: m.activeRemoteCtrl) {
|
||||
UIApplication.shared.isIdleTimerDisabled = $0
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .unlinkDesktop(rc):
|
||||
Alert(
|
||||
title: Text("Unlink desktop?"),
|
||||
primaryButton: .destructive(Text("Unlink")) {
|
||||
unlinkDesktop(rc)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .disconnectDesktop(action):
|
||||
Alert(
|
||||
title: Text("Disconnect desktop?"),
|
||||
primaryButton: .destructive(Text("Disconnect")) {
|
||||
disconnectDesktop(action)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .badInvitationError:
|
||||
Alert(title: Text("Bad desktop address"))
|
||||
case let .badVersionError(v):
|
||||
Alert(
|
||||
title: Text("Incompatible version"),
|
||||
message: Text("Desktop app version \(v ?? "") is not compatible with this app.")
|
||||
)
|
||||
case .desktopDisconnectedError:
|
||||
Alert(title: Text("Connection terminated"))
|
||||
case let .error(title, error):
|
||||
Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||
}
|
||||
|
||||
private func connectDesktopView(showScanner: Bool = true) -> some View {
|
||||
List {
|
||||
Section("This device name") {
|
||||
devicesView()
|
||||
}
|
||||
if showScanner {
|
||||
scanDesctopAddressView()
|
||||
}
|
||||
if developerTools {
|
||||
desktopAddressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect to desktop")
|
||||
}
|
||||
|
||||
private func connectingDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> some View {
|
||||
List {
|
||||
Section("Connecting to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
List {
|
||||
Section("Connected to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
Section("Verify code with desktop") {
|
||||
sessionCodeText(sessCode)
|
||||
Button {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
} label: {
|
||||
Label("Confirm", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verify connection")
|
||||
}
|
||||
|
||||
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
|
||||
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "")
|
||||
if (rc == nil) {
|
||||
t = t + Text(" ") + Text("(new)").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text {
|
||||
let v = session.ctrlAppInfo?.appVersionRange.maxVersion
|
||||
var t = Text("v\(v ?? "")")
|
||||
if v != session.appVersion {
|
||||
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func activeSessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo) -> some View {
|
||||
List {
|
||||
Section("Connected desktop") {
|
||||
Text(rc.deviceViewName)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
} footer: {
|
||||
// This is specific to iOS
|
||||
Text("Keep the app open to use it from desktop")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connected to desktop")
|
||||
}
|
||||
|
||||
private func sessionCodeText(_ code: String) -> some View {
|
||||
Text(code.prefix(23))
|
||||
}
|
||||
|
||||
private func devicesView() -> some View {
|
||||
Group {
|
||||
TextField("Enter this device name…", text: $deviceName)
|
||||
if !remoteCtrls.isEmpty {
|
||||
NavigationLink {
|
||||
linkedDesktopsView()
|
||||
} label: {
|
||||
Text("Linked desktops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanDesctopAddressView() -> some View {
|
||||
Section("Scan QR code from desktop") {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func desktopAddressView() -> some View {
|
||||
Section("Desktop address") {
|
||||
if sessionAddress.isEmpty {
|
||||
Button {
|
||||
sessionAddress = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Label("Paste desktop address", systemImage: "doc.plaintext")
|
||||
}
|
||||
.disabled(!UIPasteboard.general.hasStrings)
|
||||
} else {
|
||||
HStack {
|
||||
Text(sessionAddress).lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.onTapGesture { sessionAddress = "" }
|
||||
}
|
||||
}
|
||||
Button {
|
||||
connectDesktopAddress(sessionAddress)
|
||||
} label: {
|
||||
Label("Connect to desktop", systemImage: "rectangle.connected.to.line.below")
|
||||
}
|
||||
.disabled(sessionAddress.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkedDesktopsView() -> some View {
|
||||
List {
|
||||
Section("Desktop devices") {
|
||||
ForEach(remoteCtrls, id: \.remoteCtrlId) { rc in
|
||||
remoteCtrlView(rc)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let i = indexSet.first, i < remoteCtrls.count {
|
||||
alert = .unlinkDesktop(rc: remoteCtrls[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Linked desktop options") {
|
||||
Toggle("Verify connections", isOn: $confirmRemoteSessions)
|
||||
Toggle("Discover via local network", isOn: $connectRemoteViaMulticast)
|
||||
if connectRemoteViaMulticast {
|
||||
Toggle("Connect automatically", isOn: $connectRemoteViaMulticastAuto)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Linked desktops")
|
||||
}
|
||||
|
||||
private func remoteCtrlView(_ rc: RemoteCtrlInfo) -> some View {
|
||||
Text(rc.deviceViewName)
|
||||
}
|
||||
|
||||
|
||||
private func setDeviceName(_ name: String) {
|
||||
do {
|
||||
try setLocalDeviceName(deviceName)
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteCtrls() {
|
||||
do {
|
||||
remoteCtrls = try listRemoteCtrls()
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func processDesktopQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r): connectDesktopAddress(r.string)
|
||||
case let .failure(e): errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
connectDesktop_ {
|
||||
try await connectRemoteCtrl(desktopAddress: addr)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectDesktop_(_ connect: @escaping () async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String)) {
|
||||
Task {
|
||||
do {
|
||||
let (rc_, ctrlAppInfo, v) = try await connect()
|
||||
await MainActor.run {
|
||||
sessionAddress = ""
|
||||
m.remoteCtrlSession = RemoteCtrlSession(
|
||||
ctrlAppInfo: ctrlAppInfo,
|
||||
appVersion: v,
|
||||
sessionState: .connecting(remoteCtrl_: rc_)
|
||||
)
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
switch e as? ChatResponse {
|
||||
case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError
|
||||
case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError
|
||||
case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError
|
||||
default: errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyDesktopSessionCode(_ sessCode: String) {
|
||||
Task {
|
||||
do {
|
||||
let rc = try await verifyRemoteCtrlSession(sessCode)
|
||||
await MainActor.run {
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(.connected(remoteCtrl: rc, sessionCode: sessCode))
|
||||
}
|
||||
await MainActor.run {
|
||||
updateRemoteCtrls()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
errorAlert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectButton(_ label: LocalizedStringKey = "Disconnect") -> some View {
|
||||
Button {
|
||||
disconnectDesktop(.dismiss)
|
||||
} label: {
|
||||
Label(label, systemImage: "multiply")
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectDesktop(_ action: UserDisconnectAction? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await stopRemoteCtrl()
|
||||
await MainActor.run {
|
||||
if case .connected = m.remoteCtrlSession?.sessionState {
|
||||
switchToLocalSession()
|
||||
} else {
|
||||
m.remoteCtrlSession = nil
|
||||
}
|
||||
switch action {
|
||||
case .back: dismiss()
|
||||
case .dismiss: dismiss()
|
||||
case .none: ()
|
||||
}
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unlinkDesktop(_ rc: RemoteCtrlInfo) {
|
||||
Task {
|
||||
do {
|
||||
try await deleteRemoteCtrl(rc.remoteCtrlId)
|
||||
await MainActor.run {
|
||||
remoteCtrls.removeAll(where: { $0.remoteCtrlId == rc.remoteCtrlId })
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func errorAlert(_ error: Error) {
|
||||
let a = getErrorAlert(error, "Error")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConnectDesktopView()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user