Compare commits

..

1 Commits

Author SHA1 Message Date
Avently 95f4a8c906 desktop: responsive design for large and small screens 2023-07-28 18:14:36 +07:00
738 changed files with 19900 additions and 86002 deletions
+15 -205
View File
@@ -8,7 +8,6 @@ on:
- users - users
tags: tags:
- "v*" - "v*"
- "!*-fdroid"
pull_request: pull_request:
jobs: jobs:
@@ -53,19 +52,15 @@ jobs:
- os: ubuntu-20.04 - os: ubuntu-20.04
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64 asset_name: simplex-chat-ubuntu-20_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04 - os: ubuntu-22.04
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64 asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- os: macos-latest - os: macos-latest
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64 asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest - os: windows-latest
cache_path: C:/cabal cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64 asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps: steps:
- name: Configure pagefile (Windows) - name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
@@ -79,10 +74,10 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Haskell - name: Setup Haskell
uses: haskell-actions/setup@v2 uses: haskell/actions/setup@v2
with: with:
ghc-version: "9.6.3" ghc-version: "8.10.7"
cabal-version: "3.10.1.0" cabal-version: "latest"
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -104,10 +99,6 @@ jobs:
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac - name: Install pkg-config for Mac
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
run: brew install pkg-config run: brew install pkg-config
@@ -120,136 +111,23 @@ jobs:
echo "package direct-sqlcipher" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
- name: Unix build CLI - name: Unix build
id: unix_cli_build id: unix_build
if: matrix.os != 'windows-latest' if: matrix.os != 'windows-latest'
shell: bash shell: bash
run: | run: |
cabal build --enable-tests cabal build --enable-tests
path=$(cabal list-bin simplex-chat) echo "::set-output name=bin_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
- name: Unix upload CLI binary to release - name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} 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 }} asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Unix 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.unix_cli_build.outputs.bin_hash }}
- name: Setup Java
if: startsWith(github.ref, 'refs/tags/v')
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.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.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.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.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.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.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: Unix test - name: Unix test
if: matrix.os != 'windows-latest' if: matrix.os != 'windows-latest'
timeout-minutes: 30 timeout-minutes: 30
@@ -259,47 +137,21 @@ jobs:
# Unix / # Unix /
# / Windows # / 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 - name: Windows build
id: windows_build id: windows_build
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
shell: msys2 {0} shell: cmd
run: | run: |
export PATH=$PATH:/c/ghcup/bin
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 cabal build --enable-tests
rm -rf dist-newstyle/src/direct-sq* cabal list-bin simplex-chat > tmp_bin_path
path=$(cabal list-bin simplex-chat | tail -n 1) set /p bin_path= < tmp_bin_path
echo "bin_path=$path" >> $GITHUB_OUTPUT echo ::set-output name=bin_path::%bin_path%
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Windows upload CLI binary to release - name: Windows upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
@@ -308,46 +160,4 @@ jobs:
asset_name: ${{ matrix.asset_name }} asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }} 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
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 }}
# Windows / # Windows /
-1
View File
@@ -9,7 +9,6 @@ on:
- website/** - website/**
- images/** - images/**
- blog/** - blog/**
- docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
jobs: jobs:
-1
View File
@@ -53,7 +53,6 @@ website/src/docs/
website/translations.json website/translations.json
website/src/img/images/ website/src/img/images/
website/src/images/ website/src/images/
website/src/js/lottie.min.js
# Generated files # Generated files
website/package/generated* website/package/generated*
+4 -4
View File
@@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
chmod +x /usr/bin/ghcup chmod +x /usr/bin/ghcup
# Install ghc # Install ghc
RUN ghcup install ghc 9.6.3 RUN ghcup install ghc 8.10.7
# Install cabal # Install cabal
RUN ghcup install cabal 3.10.1.0 RUN ghcup install cabal
# Set both as default # Set both as default
RUN ghcup set ghc 9.6.3 && \ RUN ghcup set ghc 8.10.7 && \
ghcup set cabal 3.10.1.0 ghcup set cabal
COPY . /project COPY . /project
WORKDIR /project WORKDIR /project
+16 -54
View File
@@ -6,63 +6,27 @@ If you believe that some of the clauses in this document are not aligned with ou
## Privacy Policy ## 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 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).
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).
### Information you provide ### 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. 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).
#### 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
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. 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 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.
[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.
### Information we may share ### 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 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: 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 ### 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 ## 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. **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. **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. **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 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.
**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.
**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. **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. **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. **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. **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
+34 -64
View File
@@ -2,7 +2,7 @@
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases) [![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
<a rel="me" href="https://mastodon.social/@simplex">![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)</a> [![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex)
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) | | 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
@@ -15,7 +15,7 @@
## Welcome to SimpleX Chat! ## Welcome to SimpleX Chat!
1. 📲 [Install the app](#install-the-app). 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. 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations). 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**! - 🚀 [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. - 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
## Connect to the team ## Connect to the team via the app
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:
- to ask any questions - to ask any questions
- to suggest any improvements - to suggest any improvements
- to share anything relevant - 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 ## 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. **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: You also can:
- criticize the app, and make comparisons with other messengers. - 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 new messengers you think could be interesting for privacy, as long as you don't spam.
- share some privacy related publications, infrequently. - 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: You must:
- be polite to other users - be polite to other users
@@ -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. 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 ## 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. 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,22 +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| |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 | |✓|✓|✓|✓| |🇬🇧 en|English | |✓|✓|✓|✓|
|ar|العربية |[jermanuts](https://github.com/jermanuts)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/ar/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/ar/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ar/)|| |ar|العربية |[jermanuts](https://github.com/jermanuts)||[![website](https://hosted.weblate.org/widgets/simplex-chat/ar/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|🇧🇬 bg|Български | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/bg/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[![ios app](https://hosted.weblate.org/widget/simplex-chat/ios/bg/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/cs/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/cs/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/cs/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)| |🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/cs/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/cs/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/cs/website/svg-badge.svg)](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)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/de/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/de/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/de/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/de/)|| |🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/de/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/de/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/de/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)|| |🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)| |🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)|| |🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)|| |🇯🇵 ja|Japanese ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)|| |🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/pl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)||| |🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)|| |🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)||| |🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)|| |🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us! Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
@@ -162,14 +140,11 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us. - [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. - [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG - Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG - BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- USDT: - Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2 - Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you, Thank you,
@@ -189,7 +164,7 @@ SimpleX Chat founder
- [News and updates](#news-and-updates) - [News and updates](#news-and-updates)
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app) - [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
- [SimpleX Platform design](#simplex-platform-design) - [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) - [For developers](#for-developers)
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers) - [Disclaimers, Security contact, License](#disclaimers)
@@ -230,20 +205,24 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates ## News and updates
Recent and important updates: Recent updates:
[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). [July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md). [May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 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). [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). [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). [Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
@@ -276,7 +255,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). 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. 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.
@@ -289,23 +268,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. 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). 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. 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. 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. 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. 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. 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. 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, except videos (to be added later).
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. 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. Post-quantum resistant key exchange in double ratchet protocol. 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. 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). 3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
## For developers ## For developers
@@ -366,26 +340,22 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Message editing history - ✅ Message editing history
- ✅ Reduced battery and traffic usage in large groups. - ✅ Reduced battery and traffic usage in large groups.
- ✅ Message delivery confirmation (with sender opt-out per contact). - ✅ Message delivery confirmation (with sender opt-out per contact).
- Desktop client. - 🏗 Desktop client.
- ✅ Encryption of local files stored in the app.
- ✅ Using mobile profiles from the desktop app.
- 🏗 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.
- Improve sending videos (including encryption of locally stored videos).
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - Include optional message into connection request sent via contact address.
- Local app files encryption.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
- Large groups, communities and public channels.
- Feeds/broadcasts. - Feeds/broadcasts.
- Ephemeral/disappearing/OTR conversations with the existing contacts. - Ephemeral/disappearing/OTR conversations with the existing contacts.
- Privately share your location. - Privately share your location.
- Web widgets for custom interactivity in the chats. - Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.). - 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: - 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. - keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups. - 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. - High capacity multi-node SMP relays.
## Disclaimers ## Disclaimers
+1 -4
View File
@@ -55,6 +55,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable : Any], didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
logger.debug("AppDelegate: didReceiveRemoteNotification") logger.debug("AppDelegate: didReceiveRemoteNotification")
print("*** userInfo", userInfo)
let m = ChatModel.shared let m = ChatModel.shared
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
m.notificationMode != .off { m.notificationMode != .off {
@@ -120,10 +121,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
BGManager.shared.receiveMessages(complete) BGManager.shared.receiveMessages(complete)
} }
static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on
}
} }
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
+27 -41
View File
@@ -28,17 +28,6 @@ struct ContentView: View {
@State private var showWhatsNew = false @State private var showWhatsNew = false
@State private var showChooseLAMode = false @State private var showChooseLAMode = false
@State private var showSetPasscode = false @State private var showSetPasscode = false
@State private var chatListActionSheet: ChatListActionSheet? = nil
private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
var id: String {
switch self {
case let .planAndConnectSheet(sheet): return sheet.id
}
}
}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -91,11 +80,6 @@ struct ContentView: View {
if case .onboardingComplete = step, if case .onboardingComplete = step,
chatModel.currentUser != nil { chatModel.currentUser != nil {
mainView() mainView()
.actionSheet(item: $chatListActionSheet) { sheet in
switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
}
} else { } else {
OnboardingView(onboarding: step) OnboardingView(onboarding: step)
} }
@@ -148,9 +132,7 @@ struct ContentView: View {
} }
} }
prefShowLANotice = true prefShowLANotice = true
connectViaUrl()
} }
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) { .sheet(isPresented: $showWhatsNew) {
WhatsNewView() WhatsNewView()
} }
@@ -283,32 +265,36 @@ struct ContentView: View {
secondaryButton: .cancel() secondaryButton: .cancel()
) )
} }
}
func connectViaUrl() { func connectViaUrl() {
dismissAllSheets() { let m = ChatModel.shared
let m = ChatModel.shared if let url = m.appOpenUrl {
if let url = m.appOpenUrl { m.appOpenUrl = nil
m.appOpenUrl = nil AlertManager.shared.showAlert(connectViaUrlAlert(url))
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")))
}
}
}
} }
}
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { func connectViaUrlAlert(_ url: URL) -> Alert {
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) 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"))
} }
} }
+3 -41
View File
@@ -46,7 +46,6 @@ class AudioRecorder {
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH) audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
await MainActor.run { await MainActor.run {
AppDelegate.keepScreenOn(true)
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
guard let time = self.audioRecorder?.currentTime else { return } guard let time = self.audioRecorder?.currentTime else { return }
self.onTimer?(time) self.onTimer?(time)
@@ -58,10 +57,6 @@ class AudioRecorder {
} }
return nil return nil
} catch let error { } catch let error {
await MainActor.run {
AppDelegate.keepScreenOn(false)
}
try? av.setCategory(AVAudioSession.Category.soloAmbient)
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)") logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
return .error(error.localizedDescription) return .error(error.localizedDescription)
} }
@@ -76,8 +71,6 @@ class AudioRecorder {
timer.invalidate() timer.invalidate()
} }
recordingTimer = nil recordingTimer = nil
AppDelegate.keepScreenOn(false)
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient)
} }
private func checkPermission() async -> Bool { private func checkPermission() async -> Bool {
@@ -110,15 +103,9 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
self.onFinishPlayback = onFinishPlayback self.onFinishPlayback = onFinishPlayback
} }
func start(fileSource: CryptoFile, at: TimeInterval?) { func start(fileName: String, at: TimeInterval?) {
let url = getAppFilePath(fileSource.filePath) let url = getAppFilePath(fileName)
if let cfArgs = fileSource.cryptoArgs { audioPlayer = try? AVAudioPlayer(contentsOf: url)
if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) {
audioPlayer = try? AVAudioPlayer(data: data)
}
} else {
audioPlayer = try? AVAudioPlayer(contentsOf: url)
}
audioPlayer?.delegate = self audioPlayer?.delegate = self
audioPlayer?.prepareToPlay() audioPlayer?.prepareToPlay()
if let at = at { if let at = at {
@@ -128,19 +115,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
if self.audioPlayer?.isPlaying ?? false { if self.audioPlayer?.isPlaying ?? false {
AppDelegate.keepScreenOn(true)
guard let time = self.audioPlayer?.currentTime else { return } guard let time = self.audioPlayer?.currentTime else { return }
self.onTimer?(time) self.onTimer?(time)
AudioPlayer.changeAudioSession(true)
} else {
AudioPlayer.changeAudioSession(false)
} }
} }
} }
func pause() { func pause() {
audioPlayer?.pause() audioPlayer?.pause()
AppDelegate.keepScreenOn(false)
} }
func play() { func play() {
@@ -161,8 +143,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
func stop() { func stop() {
if let player = audioPlayer { if let player = audioPlayer {
player.stop() player.stop()
AppDelegate.keepScreenOn(false)
AudioPlayer.changeAudioSession(false)
} }
audioPlayer = nil audioPlayer = nil
if let timer = playbackTimer { if let timer = playbackTimer {
@@ -171,24 +151,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
playbackTimer = nil 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) { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stop() stop()
self.onFinishPlayback?() self.onFinishPlayback?()
+66 -186
View File
@@ -11,38 +11,6 @@ import Combine
import SwiftUI import SwiftUI
import SimpleXChat 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 { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@@ -62,11 +30,9 @@ final class ChatModel: ObservableObject {
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = [] @Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GroupMember] = []
// items in the terminal view // items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = [] @Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink? @Published var userAddress: UserContactLink?
@Published var chatItemTTL: ChatItemTTL = .none @Published var chatItemTTL: ChatItemTTL = .none
@@ -77,16 +43,15 @@ final class ChatModel: ObservableObject {
@Published var tokenStatus: NtfTknStatus? @Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off @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 // pending notification actions
@Published var ntfContactRequest: NTFContactRequest? @Published var ntfContactRequest: ChatId?
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)? @Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
// current WebRTC call // current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:] @Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call? @Published var activeCall: Call?
@Published var callCommand: WCallCommand? @Published var callCommand: WCallCommand?
@Published var showCallView = false @Published var showCallView = false
// remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession?
// currently showing QR code // currently showing QR code
@Published var connReqInv: String? @Published var connReqInv: String?
// audio recording and playback // audio recording and playback
@@ -112,10 +77,6 @@ final class ChatModel: ObservableObject {
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
} }
var activeRemoteCtrl: Bool {
remoteCtrlSession?.active ?? false
}
func getUser(_ userId: Int64) -> User? { func getUser(_ userId: Int64) -> User? {
currentUser?.userId == userId currentUser?.userId == userId
? currentUser ? currentUser
@@ -159,20 +120,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? { private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id }) chats.firstIndex(where: { $0.id == id })
} }
@@ -186,7 +133,6 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatInfo = cInfo chats[i].chatInfo = cInfo
chats[i].created = Date.now
} }
} }
@@ -200,7 +146,7 @@ final class ChatModel: ObservableObject {
func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) { func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) {
var updatedConn = contact.activeConn var updatedConn = contact.activeConn
updatedConn?.connectionStats = connectionStats updatedConn.connectionStats = connectionStats
var updatedContact = contact var updatedContact = contact
updatedContact.activeConn = updatedConn updatedContact.activeConn = updatedConn
updateContact(updatedContact) updateContact(updatedContact)
@@ -318,11 +264,7 @@ final class ChatModel: ObservableObject {
return false return false
} else { } else {
withAnimation(itemAnimation()) { withAnimation(itemAnimation()) {
var ci = cItem reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
} }
return true return true
} }
@@ -335,22 +277,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) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _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) { private func _updateChatItem(at i: Int, with cItem: ChatItem) {
let ci = reversedChatItems[i]
reversedChatItems[i] = cItem reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now 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 }) reversedChatItems.firstIndex(where: { $0.id == cItem.id })
} }
@@ -486,7 +432,6 @@ final class ChatModel: ObservableObject {
} }
// clear current chat // clear current chat
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:]
reversedChatItems = [] reversedChatItems = []
} }
} }
@@ -518,18 +463,18 @@ final class ChatModel: ObservableObject {
} }
} }
func increaseUnreadCounter(user: any UserLike) { func increaseUnreadCounter(user: User) {
changeUnreadCounter(user: user, by: 1) changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount() NtfManager.shared.incNtfBadgeCount()
} }
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { func decreaseUnreadCounter(user: User, by: Int = 1) {
changeUnreadCounter(user: user, by: -by) changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by) NtfManager.shared.decNtfBadgeCount(by: by)
} }
private func changeUnreadCounter(user: any UserLike, by: Int) { private func changeUnreadCounter(user: User, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) { if let i = users.firstIndex(where: { $0.user.id == user.id }) {
users[i].unreadCount += by users[i].unreadCount += by
} }
} }
@@ -539,62 +484,14 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) 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 getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
var count = 0 return reversedChatItems[i + 1]
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)
} else { } 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) { func popChat(_ id: String) {
if let i = getChatIndex(id) { if let i = getChatIndex(id) {
popChat_(i) popChat_(i)
@@ -629,14 +526,13 @@ final class ChatModel: ObservableObject {
} }
// update current chat // update current chat
if chatId == groupInfo.id { 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) { withAnimation(.default) {
self.groupMembers[i].wrapped = member self.groupMembers[i] = member
self.groupMembers[i].created = Date.now
} }
return false return false
} else { } else {
withAnimation { groupMembers.append(GMember(member)) } withAnimation { groupMembers.append(member) }
return true return true
} }
} else { } else {
@@ -645,10 +541,11 @@ final class ChatModel: ObservableObject {
} }
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
if var conn = member.activeConn { if let conn = member.activeConn {
conn.connectionStats = connectionStats var updatedConn = conn
updatedConn.connectionStats = connectionStats
var updatedMember = member var updatedMember = member
updatedMember.activeConn = conn updatedMember.activeConn = updatedConn
_ = upsertGroupMember(groupInfo, updatedMember) _ = upsertGroupMember(groupInfo, updatedMember)
} }
} }
@@ -677,23 +574,19 @@ final class ChatModel: ObservableObject {
} }
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn { networkStatuses[contact.activeConn.agentConnId] = status
networkStatuses[conn.agentConnId] = status
}
} }
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn { networkStatuses[contact.activeConn.agentConnId] ?? .unknown
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
} }
}
struct NTFContactRequest { func addTerminalItem(_ item: TerminalItem) {
var incognito: Bool if terminalItems.count >= 500 {
var chatId: String terminalItems.remove(at: 0)
}
terminalItems.append(item)
}
} }
struct UnreadChatItemCounts { struct UnreadChatItemCounts {
@@ -753,53 +646,40 @@ final class Chat: ObservableObject, Identifiable {
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
} }
final class GMember: ObservableObject, Identifiable { enum NetworkStatus: Decodable, Equatable {
@Published var wrapped: GroupMember case unknown
var created = Date.now case connected
case disconnected
case error(String)
init(_ member: GroupMember) { var statusString: LocalizedStringKey {
self.wrapped = member get {
switch self {
case .connected: return "connected"
case .error: return "error"
default: return "connecting"
}
}
} }
var id: String { wrapped.id } var statusExplanation: LocalizedStringKey {
var groupId: Int64 { wrapped.groupId } get {
var groupMemberId: Int64 { wrapped.groupMemberId } switch self {
var displayName: String { wrapped.displayName } case .connected: return "You are connected to the server used to receive messages from this contact."
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
static let sampleData = GMember(GroupMember.sampleData) default: return "Trying to connect to the server used to receive messages from this contact."
} }
}
struct RemoteCtrlSession {
var ctrlAppInfo: CtrlAppInfo?
var appVersion: String
var sessionState: UIRemoteCtrlSessionState
func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession {
RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state)
} }
var active: Bool { var imageName: String {
if case .connected = sessionState { true } else { false } get {
} switch self {
case .unknown: return "circle.dotted"
var discovery: Bool { case .connected: return "circle.fill"
if case .searching = sessionState { true } else { false } case .disconnected: return "ellipsis.circle.fill"
} case .error: return "exclamationmark.circle.fill"
}
var sessionCode: String? {
switch sessionState {
case let .pendingConfirmation(_, sessionCode): sessionCode
case let .connected(_, sessionCode): sessionCode
default: nil
} }
} }
} }
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)
}
+37 -42
View File
@@ -11,43 +11,42 @@ import SimpleXChat
import SwiftUI import SwiftUI
import AVKit import AVKit
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { func getLoadedFilePath(_ file: CIFile?) -> String? {
if let file = file, file.loaded { if let fileName = getLoadedFileName(file) {
return file.fileSource 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 return nil
} }
func getLoadedImage(_ file: CIFile?) -> UIImage? { func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) { let loadedFilePath = getLoadedFilePath(file)
let filePath = getAppFilePath(fileSource.filePath) if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
let filePath = getAppFilePath(fileName)
do { do {
let data = try getFileData(filePath, fileSource.cryptoArgs) let data = try Data(contentsOf: filePath)
let img = UIImage(data: data) let img = UIImage(data: data)
do { try img?.setGifFromData(data, levelOfIntegrity: 1.0)
try img?.setGifFromData(data, levelOfIntegrity: 1.0) return img
return img
} catch {
return UIImage(data: data)
}
} catch { } catch {
return nil return UIImage(contentsOfFile: loadedFilePath)
} }
} }
return nil 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? { func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) { let loadedFilePath = getLoadedFilePath(file)
let filePath = getAppFilePath(fileSource.filePath) if loadedFilePath != nil, let fileName = file?.filePath {
let filePath = getAppFilePath(fileName)
if FileManager.default.fileExists(atPath: filePath.path) { if FileManager.default.fileExists(atPath: filePath.path) {
return filePath return filePath
} }
@@ -55,18 +54,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil return nil
} }
func saveAnimImage(_ image: UIImage) -> CryptoFile? { func saveAnimImage(_ image: UIImage) -> String? {
let fileName = generateNewFileName("IMG", "gif") let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil } 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 hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
let fileName = generateNewFileName("IMG", ext) let fileName = generateNewFileName("IMG", ext)
return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) return saveFile(imageDataResized, fileName)
} }
return nil return nil
} }
@@ -158,19 +157,13 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { func saveFileFromURL(_ url: URL) -> String? {
let savedFile: CryptoFile? let savedFile: String?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
do { do {
let fileData = try Data(contentsOf: url)
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
let toPath = getAppFilePath(fileName).path savedFile = saveFile(fileData, fileName)
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)
}
} catch { } catch {
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
savedFile = nil savedFile = nil
@@ -183,16 +176,18 @@ func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
return savedFile return savedFile
} }
func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
let savedFile: String?
do { do {
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
ChatModel.shared.filesToDelete.remove(url) ChatModel.shared.filesToDelete.remove(url)
return CryptoFile.plain(fileName) savedFile = fileName
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
return nil savedFile = nil
} }
return savedFile
} }
func generateNewFileName(_ prefix: String, _ ext: String) -> String { func generateNewFileName(_ prefix: String, _ ext: String) -> String {
@@ -293,4 +288,4 @@ extension UIImage {
} }
return self return self
} }
} }
+11 -19
View File
@@ -12,7 +12,6 @@ import UIKit
import SimpleXChat import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
@@ -42,13 +41,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
userId != chatModel.currentUser?.userId { userId != chatModel.currentUser?.userId {
changeActiveUser(userId, viewPwd: nil) 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 chatId = content.userInfo["chatId"] as? String {
let incognito = action == ntfActionAcceptContactIncognito
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) } Task { await acceptContactRequest(contactRequest) }
} else { } else {
chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId) chatModel.ntfContactRequest = chatId
} }
} else if let (chatId, ntfAction) = ntfCallAction(content, action) { } else if let (chatId, ntfAction) = ntfCallAction(content, action) {
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
@@ -136,17 +134,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
UNUserNotificationCenter.current().setNotificationCategories([ UNUserNotificationCenter.current().setNotificationCategories([
UNNotificationCategory( UNNotificationCategory(
identifier: ntfCategoryContactRequest, identifier: ntfCategoryContactRequest,
actions: [ actions: [UNNotificationAction(
UNNotificationAction( identifier: ntfActionAcceptContact,
identifier: ntfActionAcceptContact, title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
title: NSLocalizedString("Accept", comment: "accept contact request via notification"), options: .foreground
options: .foreground )],
), UNNotificationAction(
identifier: ntfActionAcceptContactIncognito,
title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
options: .foreground
)
],
intentIdentifiers: [], intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification")
), ),
@@ -211,17 +203,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
center.delegate = self center.delegate = self
} }
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest") logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest)) addNotification(createContactRequestNtf(user, contactRequest))
} }
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) { func notifyContactConnected(_ user: User, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected") logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact)) 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") logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled { if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem)) addNotification(createMessageReceivedNtf(user, cInfo, cItem))
File diff suppressed because it is too large Load Diff
+5 -23
View File
@@ -19,11 +19,7 @@ let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds let terminationTimeout: Int = 3 // seconds
private func _suspendChat(timeout: Int) { private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR if ChatModel.ok {
let state = appStateGroupDefault.get()
if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
} else if ChatModel.ok {
appStateGroupDefault.set(.suspending) appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000) apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended) let endTask = beginBGTask(chatSuspended)
@@ -35,7 +31,9 @@ private func _suspendChat(timeout: Int) {
func suspendChat() { func suspendChat() {
suspendLockQueue.sync { suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout) if appStateGroupDefault.get() != .stopped {
_suspendChat(timeout: appSuspendTimeout)
}
} }
} }
@@ -47,25 +45,15 @@ func suspendBgRefresh() {
} }
} }
private var terminating = false
func terminateChat() { func terminateChat() {
logger.debug("terminateChat")
suspendLockQueue.sync { suspendLockQueue.sync {
switch appStateGroupDefault.get() { switch appStateGroupDefault.get() {
case .suspending: case .suspending:
// suspend instantly if already suspending // suspend instantly if already suspending
_chatSuspended() _chatSuspended()
// when apiSuspendChat is called with timeout 0, it won't send any events on suspension
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) } if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
chatCloseStore() case .stopped: ()
case .suspended:
chatCloseStore()
case .stopped:
chatCloseStore()
default: default:
terminating = true
// the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout) _suspendChat(timeout: terminationTimeout)
} }
} }
@@ -85,14 +73,10 @@ private func _chatSuspended() {
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
} }
if terminating {
chatCloseStore()
}
} }
func activateChat(appState: AppState = .active) { func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat") logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync { suspendLockQueue.sync {
appStateGroupDefault.set(appState) appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() } if ChatModel.ok { apiActivateChat() }
@@ -101,7 +85,6 @@ func activateChat(appState: AppState = .active) {
} }
func initChatAndMigrate(refreshInvitations: Bool = true) { func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
do { do {
@@ -114,7 +97,6 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
} }
func startChatAndActivate() { func startChatAndActivate() {
terminating = false
logger.debug("DEBUGGING: startChatAndActivate") logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start() ChatReceiver.shared.start()
+4 -7
View File
@@ -26,10 +26,7 @@ struct SimpleXApp: App {
@State private var showInitializationView = false @State private var showInitializationView = false
init() { init() {
DispatchQueue.global(qos: .background).sync { hs_init(0, nil)
haskell_init()
// hs_init(0, nil)
}
UserDefaults.standard.register(defaults: appDefaults) UserDefaults.standard.register(defaults: appDefaults)
setGroupDefaults() setGroupDefaults()
registerGroupDefaults() registerGroupDefaults()
@@ -142,10 +139,10 @@ struct SimpleXApp: App {
let chat = chatModel.getChat(id) { let chat = chatModel.getChat(id) {
loadChat(chat: chat) loadChat(chat: chat)
} }
if let ncr = chatModel.ntfContactRequest { if let chatId = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } Task { await acceptContactRequest(contactRequest) }
} }
} }
} catch let error { } catch let error {
@@ -39,7 +39,6 @@ struct ActiveCallView: View {
} }
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
} }
@@ -49,7 +48,6 @@ struct ActiveCallView: View {
} }
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.onChange(of: m.callCommand) { _ in sendCommandToClient()} .onChange(of: m.callCommand) { _ in sendCommandToClient()}
@@ -108,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(true) try audioSession.setActive(true)
logger.debug("audioSession activated") logger.debug("audioSession activated")
} catch { } catch {
print(error)
logger.error("failed activating audio session") logger.error("failed activating audio session")
} }
} }
@@ -120,6 +121,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(false) try audioSession.setActive(false)
logger.debug("audioSession deactivated") logger.debug("audioSession deactivated")
} catch { } catch {
print(error)
logger.error("failed deactivating audio session") logger.error("failed deactivating audio session")
} }
suspendOnEndCall() suspendOnEndCall()
+50 -69
View File
@@ -99,12 +99,12 @@ struct ChatInfoView: View {
@Binding var connectionCode: String? @Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil @State private var alert: ChatInfoViewAlert? = nil
@State private var showDeleteContactActionSheet = false
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum ChatInfoViewAlert: Identifiable { enum ChatInfoViewAlert: Identifiable {
case deleteContactAlert
case clearChatAlert case clearChatAlert
case networkStatusAlert case networkStatusAlert
case switchAddressAlert case switchAddressAlert
@@ -114,6 +114,7 @@ struct ChatInfoView: View {
var id: String { var id: String {
switch self { switch self {
case .deleteContactAlert: return "deleteContactAlert"
case .clearChatAlert: return "clearChatAlert" case .clearChatAlert: return "clearChatAlert"
case .networkStatusAlert: return "networkStatusAlert" case .networkStatusAlert: return "networkStatusAlert"
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
@@ -142,12 +143,7 @@ struct ChatInfoView: View {
if let customUserProfile = customUserProfile { if let customUserProfile = customUserProfile {
Section("Incognito") { Section("Incognito") {
HStack { infoRow("Your random profile", customUserProfile.chatViewName)
Text("Your random profile")
Spacer()
Text(customUserProfile.chatViewName)
.foregroundStyle(.indigo)
}
} }
} }
@@ -163,13 +159,12 @@ struct ChatInfoView: View {
// synchronizeConnectionButtonForce() // synchronizeConnectionButtonForce()
// } // }
} }
.disabled(!contact.ready || !contact.active)
if let contactLink = contact.contactLink { if let contactLink = contact.contactLink {
Section { Section {
SimpleXLinkQRCode(uri: contactLink) QRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [simplexChatLink(contactLink)]) showShareSheet(items: [contactLink])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
@@ -180,32 +175,30 @@ struct ChatInfoView: View {
} }
} }
if contact.ready && contact.active { Section("Servers") {
Section("Servers") { networkStatusRow()
networkStatusRow() .onTapGesture {
.onTapGesture { alert = .networkStatusAlert
alert = .networkStatusAlert }
} if let connStats = connectionStats {
if let connStats = connectionStats { Button("Change receiving address") {
Button("Change receiving address") { alert = .switchAddressAlert
alert = .switchAddressAlert }
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
} }
.disabled( .disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
} }
@@ -232,6 +225,7 @@ struct ChatInfoView: View {
} }
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
case .deleteContactAlert: return deleteContactAlert()
case .clearChatAlert: return clearChatAlert() case .clearChatAlert: return clearChatAlert()
case .networkStatusAlert: return networkStatusAlert() case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
@@ -240,26 +234,6 @@ struct ChatInfoView: View {
case let .error(title, error): return mkAlert(title: title, message: error) 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 { private func contactInfoHeader() -> some View {
@@ -338,7 +312,7 @@ struct ChatInfoView: View {
verify: { code in verify: { code in
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) { if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
let (verified, existingCode) = r 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 connectionCode = existingCode
DispatchQueue.main.async { DispatchQueue.main.async {
chat.chatInfo = .direct(contact: contact) chat.chatInfo = .direct(contact: contact)
@@ -432,7 +406,7 @@ struct ChatInfoView: View {
private func deleteContactButton() -> some View { private func deleteContactButton() -> some View {
Button(role: .destructive) { Button(role: .destructive) {
showDeleteContactActionSheet = true alert = .deleteContactAlert
} label: { } label: {
Label("Delete contact", systemImage: "trash") Label("Delete contact", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(Color.red)
@@ -448,23 +422,30 @@ struct ChatInfoView: View {
} }
} }
private func deleteContact(notify: Bool? = nil) { private func deleteContactAlert() -> Alert {
Task { Alert(
do { title: Text("Delete contact?"),
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) message: Text("Contact and all messages will be deleted - this cannot be undone!"),
await MainActor.run { primaryButton: .destructive(Text("Delete")) {
dismiss() Task {
chatModel.chatId = nil do {
chatModel.removeChat(chat.chatInfo.id) 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))") secondaryButton: .cancel()
let a = getErrorAlert(error, "Error deleting contact") )
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
} }
private func clearChatAlert() -> Alert { private func clearChatAlert() -> Alert {
@@ -11,7 +11,7 @@ import SimpleXChat
struct CICallItemView: View { struct CICallItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat var chatInfo: ChatInfo
var chatItem: ChatItem var chatItem: ChatItem
var status: CICallStatus var status: CICallStatus
var duration: Int var duration: Int
@@ -60,7 +60,7 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View { @ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo { if case let .direct(contact) = chatInfo {
Button { Button {
if let invitation = m.callInvitations[contact.id] { if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation) CallController.shared.answerCall(invitation: invitation)
@@ -10,92 +10,20 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature var feature: Feature
var icon: String? = nil var icon: String? = nil
var iconColor: Color var iconColor: Color
var body: some View { 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) { HStack(alignment: .bottom, spacing: 4) {
Image(systemName: icon ?? feature.iconFilled) Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
chatEventText(chatItem) chatEventText(chatItem)
} }
.padding(.horizontal, 6) .padding(.leading, 6)
.padding(.vertical, 4) .padding(.bottom, 6)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -103,6 +31,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider { struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false) let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(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 import SimpleXChat
struct CIEventView: View { struct CIEventView: View {
var eventText: Text var chatItem: ChatItem
var body: some View { var body: some View {
eventText HStack(alignment: .bottom, spacing: 0) {
.padding(.horizontal, 6) if let member = chatItem.memberDisplayName {
.padding(.vertical, 4) Text(member)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ chatEventText(chatItem)
} else {
chatEventText(chatItem)
}
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled) .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 { struct CIEventView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
CIEventView(eventText: Text("event happened")) CIEventView(chatItem: ChatItem.getGroupEventSample())
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIFeaturePreferenceView: View { struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat @EnvironmentObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var feature: ChatFeature var feature: ChatFeature
var allowed: FeatureAllowed var allowed: FeatureAllowed
@@ -80,6 +80,7 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: 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 import SimpleXChat
struct CIFileView: View { struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
var body: some View { var body: some View {
let metaReserve = edited let metaReserve = edited
? " " ? " "
: " " : " "
Button(action: fileAction) { Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) { HStack(alignment: .bottom, spacing: 6) {
fileIndicator() fileIndicator()
@@ -63,7 +62,6 @@ struct CIFileView: View {
case .rcvComplete: return true case .rcvComplete: return true
case .rcvCancelled: return false case .rcvCancelled: return false
case .rcvError: return false case .rcvError: return false
case .invalid: return false
} }
} }
return false return false
@@ -84,9 +82,8 @@ struct CIFileView: View {
if fileSizeValid() { if fileSizeValid() {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser { if let user = ChatModel.shared.currentUser {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId)
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
} }
} }
} else { } else {
@@ -111,8 +108,9 @@ struct CIFileView: View {
} }
case .rcvComplete: case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete") logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) { if let filePath = getLoadedFilePath(file) {
saveCryptoFile(fileSource) let url = URL(fileURLWithPath: filePath)
showShareSheet(items: [url])
} }
default: break default: break
} }
@@ -151,7 +149,6 @@ struct CIFileView: View {
case .rcvComplete: fileIcon("doc.fill") case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: 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 { } else {
fileIcon("doc.fill") fileIcon("doc.fill")
@@ -194,30 +191,6 @@ 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 { struct CIFileView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let sentFile: ChatItem = ChatItem( let sentFile: ChatItem = ChatItem(
@@ -235,17 +208,18 @@ struct CIFileView_Previews: PreviewProvider {
file: nil file: nil
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(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(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
} }
} }
@@ -17,45 +17,34 @@ struct CIGroupInvitationView: View {
var memberRole: GroupMemberRole var memberRole: GroupMemberRole
var chatIncognito: Bool = false var chatIncognito: Bool = false
@State private var frameWidth: CGFloat = 0 @State private var frameWidth: CGFloat = 0
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View { var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) { let v = ZStack(alignment: .bottomTrailing) {
ZStack { VStack(alignment: .leading) {
VStack(alignment: .leading) { groupInfoView(action)
groupInfoView(action) .padding(.horizontal, 2)
.padding(.horizontal, 2) .padding(.top, 8)
.padding(.top, 8) .padding(.bottom, 6)
.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()) .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 chatItem.timestampText
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -66,24 +55,11 @@ struct CIGroupInvitationView: View {
.cornerRadius(18) .cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .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 { if action {
v.onTapGesture { v.onTapGesture {
inProgress = true joinGroup(groupInvitation.groupId)
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
} }
.disabled(inProgress)
} else { } else {
v v
} }
@@ -91,7 +67,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action && !inProgress { if action {
color = chatIncognito ? .indigo : .accentColor color = chatIncognito ? .indigo : .accentColor
} else { } else {
color = Color(uiColor: .tertiaryLabel) color = Color(uiColor: .tertiaryLabel)
@@ -10,14 +10,12 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIImageView: View { struct CIImageView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem let chatItem: ChatItem
let image: String let image: String
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var imgWidth: CGFloat? @Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy? @State var scrollProxy: ScrollViewProxy?
@State var metaColor: Color
@State private var showFullScreenImage = false @State private var showFullScreenImage = false
var body: some View { var body: some View {
@@ -37,9 +35,10 @@ struct CIImageView: View {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation: case .rcvInvitation:
Task { Task {
if let user = m.currentUser { if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) await receiveFile(user: user, fileId: file.fileId)
} }
// TODO image accepted alert?
} }
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
@@ -100,7 +99,6 @@ struct CIImageView: View {
case .rcvTransfer: progressView() case .rcvTransfer: progressView()
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13) case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView() default: EmptyView()
} }
} }
@@ -111,7 +109,7 @@ struct CIImageView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(metaColor) .foregroundColor(.white)
.padding(padding) .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,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @EnvironmentObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var metaColor = Color.secondary var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
@@ -21,28 +21,27 @@ struct CIMetaView: View {
} else { } else {
let meta = chatItem.meta let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL let ttl = chat.chatInfo.timedMessagesTTL
let encrypted = chatItem.encryptedFile
switch meta.itemStatus { switch meta.itemStatus {
case let .sndSent(sndProgress): case let .sndSent(sndProgress):
switch sndProgress { switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent) case .complete: ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent) case .partial: ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .sent)
} }
case let .sndRcvd(_, sndProgress): case let .sndRcvd(_, sndProgress):
switch sndProgress { switch sndProgress {
case .complete: case .complete:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1) ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2) ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
} }
case .partial: case .partial:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1) ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2) ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd2)
} }
} }
default: default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor) ciMetaText(meta, chatTTL: ttl, color: metaColor)
} }
} }
} }
@@ -54,7 +53,7 @@ enum SentCheckmark {
case rcvd2 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, sent: SentCheckmark? = nil) -> Text {
var r = Text("") var r = Text("")
if meta.itemEdited { if meta.itemEdited {
r = r + statusIconText("pencil", color) r = r + statusIconText("pencil", color)
@@ -81,11 +80,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color =
} else if !meta.disappearing { } else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
if let enc = encrypted { return (r + meta.timestampText.foregroundColor(color)).font(.caption)
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
}
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption)
} }
private func statusIconText(_ icon: String, _ color: Color) -> Text { private func statusIconText(_ icon: String, _ color: Color) -> Text {
@@ -95,14 +90,15 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) CIMetaView(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(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(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(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(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(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) CIMetaView(chatItem: ChatItem.getDeletedContentSample())
} }
.previewLayout(.fixed(width: 360, height: 100)) .previewLayout(.fixed(width: 360, height: 100))
.environmentObject(Chat.sampleData)
} }
} }
@@ -12,11 +12,11 @@ import SimpleXChat
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
struct CIRcvDecryptionError: View { struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var chat: Chat
@ObservedObject var chat: Chat
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
var showMember = false
@State private var alert: CIRcvDecryptionErrorAlert? @State private var alert: CIRcvDecryptionErrorAlert?
enum CIRcvDecryptionErrorAlert: Identifiable { enum CIRcvDecryptionErrorAlert: Identifiable {
@@ -46,7 +46,7 @@ struct CIRcvDecryptionError: View {
do { do {
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats { if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s) ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s)
} }
} catch let error { } catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))") logger.error("apiGroupMemberInfo error: \(responseError(error))")
@@ -66,7 +66,7 @@ struct CIRcvDecryptionError: View {
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
if case let .direct(contact) = chat.chatInfo, if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats { let contactStats = contact.activeConn.connectionStats {
if contactStats.ratchetSyncAllowed { if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) { decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) } alert = .syncAllowedAlert { syncContactConnection(contact) }
@@ -80,8 +80,8 @@ struct CIRcvDecryptionError: View {
} }
} else if case let .group(groupInfo) = chat.chatInfo, } else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir, case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId), let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }),
let memberStats = mem.wrapped.activeConn?.connectionStats { let memberStats = modelMember.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed { if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) { decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
@@ -106,6 +106,9 @@ struct CIRcvDecryptionError: View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack { HStack {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
@@ -119,11 +122,11 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? .accentColor : .secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem) CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
@@ -134,16 +137,23 @@ struct CIRcvDecryptionError: View {
} }
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View { private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
func text() -> Text {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
}
return ZStack(alignment: .bottomTrailing) { return ZStack(alignment: .bottomTrailing) {
HStack { HStack {
Text(chatItem.content.text) if showMember, let member = chatItem.memberDisplayName {
.foregroundColor(.red) Text(member).fontWeight(.medium) + Text(": ") + text()
.italic() } else {
+ Text(" ") text()
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem) CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
@@ -165,8 +175,6 @@ struct CIRcvDecryptionError: View {
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .other: case .other:
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .ratchetSync:
message = Text("Encryption re-negotiation failed.")
} }
return message return message
} }
@@ -176,7 +184,7 @@ struct CIRcvDecryptionError: View {
do { do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
await MainActor.run { await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, mem, stats) ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats)
} }
} catch let error { } catch let error {
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
@@ -193,7 +201,7 @@ struct CIRcvDecryptionError: View {
do { do {
let stats = try apiSyncContactRatchet(contact.apiId, false) let stats = try apiSyncContactRatchet(contact.apiId, false)
await MainActor.run { await MainActor.run {
m.updateContactConnectionStats(contact, stats) ChatModel.shared.updateContactConnectionStats(contact, stats)
} }
} catch let error { } catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
@@ -9,10 +9,8 @@
import SwiftUI import SwiftUI
import AVKit import AVKit
import SimpleXChat import SimpleXChat
import Combine
struct CIVideoView: View { struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem private let chatItem: ChatItem
private let image: String private let image: String
@@ -24,12 +22,10 @@ struct CIVideoView: View {
@State private var scrollProxy: ScrollViewProxy? @State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil @State private var preview: UIImage? = nil
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var showFullScreenPlayer = false @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) { init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
self.chatItem = chatItem self.chatItem = chatItem
@@ -40,7 +36,6 @@ struct CIVideoView: View {
self.scrollProxy = scrollProxy self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) { if let url = getLoadedVideo(chatItem.file) {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false)) self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
self._fullPlayer = State(initialValue: AVPlayer(url: url))
self._url = State(initialValue: url) self._url = State(initialValue: url)
} }
if let data = Data(base64Encoded: dropImagePrefix(image)), if let data = Data(base64Encoded: dropImagePrefix(image)),
@@ -62,7 +57,7 @@ struct CIVideoView: View {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation: case .rcvInvitation:
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: case .xftp:
@@ -88,7 +83,7 @@ struct CIVideoView: View {
} }
if let file = file, case .rcvInvitation = file.fileStatus { if let file = file, case .rcvInvitation = file.fileStatus {
Button { Button {
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: { } label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
} }
@@ -101,10 +96,9 @@ struct CIVideoView: View {
DispatchQueue.main.async { videoWidth = w } DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
VideoPlayerView(player: player, url: url, showControls: false) VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width) .frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: m.stopPreviousRecPlay) { playingUrl in .onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
if playingUrl != url { if playingUrl != url {
player.pause() player.pause()
videoPlaying = false videoPlaying = false
@@ -119,20 +113,17 @@ struct CIVideoView: View {
player.pause() player.pause()
videoPlaying = false videoPlaying = false
case .paused: case .paused:
if canBePlayed { showFullScreenPlayer = true
showFullScreenPlayer = true
}
default: () default: ()
} }
} }
if !videoPlaying { if !videoPlaying {
Button { Button {
m.stopPreviousRecPlay = url ChatModel.shared.stopPreviousRecPlay = url
player.play() player.play()
} label: { } label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash") playPauseIcon("play.fill")
} }
.disabled(!canBePlayed)
} }
} }
loadingIndicator() loadingIndicator()
@@ -221,7 +212,6 @@ struct CIVideoView: View {
} }
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13) case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView() default: EmptyView()
} }
} }
@@ -256,11 +246,10 @@ struct CIVideoView: View {
.padding([.trailing, .top], 11) .padding([.trailing, .top], 11)
} }
// TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task { Task {
if let user = m.currentUser { if let user = ChatModel.shared.currentUser {
await receiveFile(user, file.fileId, encrypted, false) await receiveFile(user, file.fileId)
} }
} }
} }
@@ -268,7 +257,8 @@ struct CIVideoView: View {
private func fullScreenPlayer(_ url: URL) -> some View { private func fullScreenPlayer(_ url: URL) -> some View {
ZStack { ZStack {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer) VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
}
.overlay(alignment: .topLeading, content: { .overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false }, Button(action: { showFullScreenPlayer = false },
label: { label: {
@@ -291,38 +281,28 @@ 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 { .onDisappear {
if let fullScreenTimeObserver = fullScreenTimeObserver { if let fullScreenTimeObserver = fullScreenTimeObserver {
NotificationCenter.default.removeObserver(fullScreenTimeObserver) NotificationCenter.default.removeObserver(fullScreenTimeObserver)
} }
fullScreenTimeObserver = nil fullScreenTimeObserver = nil
fullPlayer?.pause()
fullPlayer?.seek(to: CMTime.zero)
publisher?.cancel()
} }
} }
} }
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) { private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem { if let item = player.currentItem {
@@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIVoiceView: View { struct CIVoiceView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@@ -92,7 +91,7 @@ struct CIVoiceView: View {
} }
private func metaView() -> some 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 .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
} }
} else { } else {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
@@ -160,8 +158,7 @@ struct VoiceMessagePlayer: View {
} }
} }
.onChange(of: chatModel.stopPreviousRecPlay) { it in .onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
audioPlayer?.stop() audioPlayer?.stop()
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
@@ -176,8 +173,8 @@ struct VoiceMessagePlayer: View {
switch playbackState { switch playbackState {
case .noPlayback: case .noPlayback:
Button { Button {
if let recordingSource = getLoadedFileSource(recordingFile) { if let recordingFileName = getLoadedFileName(recordingFile) {
startPlayback(recordingSource) startPlayback(recordingFileName)
} }
} label: { } label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
@@ -220,8 +217,8 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile) -> some View { private func downloadButton(_ recordingFile: CIFile) -> some View {
Button { Button {
Task { Task {
if let user = chatModel.currentUser { if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) await receiveFile(user: user, fileId: recordingFile.fileId)
} }
} }
} label: { } label: {
@@ -253,8 +250,8 @@ struct VoiceMessagePlayer: View {
.clipShape(Circle()) .clipShape(Circle())
} }
private func startPlayback(_ recordingSource: CryptoFile) { private func startPlayback(_ recordingFileName: String) {
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath) chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
audioPlayer = AudioPlayer( audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 }, onTimer: { playbackTime = $0 },
onFinishPlayback: { onFinishPlayback: {
@@ -262,7 +259,7 @@ struct VoiceMessagePlayer: View {
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
} }
) )
audioPlayer?.start(fileSource: recordingSource, at: playbackTime) audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing playbackState = .playing
} }
} }
@@ -285,7 +282,6 @@ struct CIVoiceView_Previews: PreviewProvider {
) )
Group { Group {
CIVoiceView( CIVoiceView(
chat: Chat.sampleData,
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
@@ -294,11 +290,12 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackTime: .constant(TimeInterval(20)), playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, 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(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), 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(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(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(chat: Chat.sampleData, chatItem: voiceMessageWtFile, 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)) .previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
} }
} }
@@ -11,15 +11,18 @@ import SimpleXChat
struct DeletedItemView: View { struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var showMember = false
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem) CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
@@ -33,8 +36,11 @@ struct DeletedItemView: View {
struct DeletedItemView_Previews: PreviewProvider { struct DeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) DeletedItemView(
chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
showMember: true
)
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }
@@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct EmojiItemView: View { struct EmojiItemView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -18,7 +17,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text) emojiText(chatItem.content.text)
.padding(.top, 8) .padding(.top, 8)
.padding(.horizontal, 6) .padding(.horizontal, 6)
CIMetaView(chat: chat, chatItem: chatItem) CIMetaView(chatItem: chatItem)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -33,8 +32,8 @@ func emojiText(_ text: String) -> Text {
struct EmojiItemView_Previews: PreviewProvider { struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
} }
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
} }
@@ -88,12 +88,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete) file: CIFile.getSample(fileStatus: .sndComplete)
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(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(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
} }
.previewLayout(.fixed(width: 360, height: 360)) .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() { private func startPlayerAndNotify() {
if let player = player { if let player = player {
m.stopPreviousRecPlay = url ChatModel.shared.stopPreviousRecPlay = url
player.play() player.play()
} }
} }
@@ -10,12 +10,12 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct IntegrityErrorItemView: View { struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
var msgError: MsgErrorType var msgError: MsgErrorType
var chatItem: ChatItem var chatItem: ChatItem
var showMember = false
var body: some View { var body: some View {
CIMsgError(chat: chat, chatItem: chatItem) { CIMsgError(chatItem: chatItem, showMember: showMember) {
switch msgError { switch msgError {
case .msgSkipped: case .msgSkipped:
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -53,16 +53,19 @@ struct IntegrityErrorItemView: View {
} }
struct CIMsgError: View { struct CIMsgError: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var showMember = false
var onTap: () -> Void var onTap: () -> Void
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem) CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
@@ -76,6 +79,6 @@ struct CIMsgError: View {
struct IntegrityErrorItemView_Previews: PreviewProvider { struct IntegrityErrorItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
} }
} }
@@ -10,70 +10,43 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool var showMember = false
var body: some View { var body: some View {
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText) HStack(alignment: .bottom, spacing: 0) {
.font(.caption) if showMember, let member = chatItem.memberDisplayName {
.foregroundColor(.secondary) Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
.padding(.horizontal, 12) }
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) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18) .cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
var mergedMarkedDeletedText: LocalizedStringKey { func markedDeletedText(_ s: LocalizedStringKey) -> some View {
if !revealed, Text(s)
let ciCategory = chatItem.mergeCategory, .font(.caption)
var i = m.getChatItemIndex(chatItem) { .foregroundColor(.secondary)
var moderated = 0 .italic()
var blocked = 0 .lineLimit(1)
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 .deleted: deleted += 1
}
i += 1
}
let total = moderated + blocked + deleted
return total <= 1
? markedDeletedText
: total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blocked
? "\(total) messages blocked"
: "\(total) messages marked deleted"
} else {
return markedDeletedText
}
}
var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
default: "marked deleted"
}
} }
} }
struct MarkedDeletedItemView_Previews: PreviewProvider { struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { 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(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }
@@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
} }
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @EnvironmentObject var chat: Chat
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var sender: String? = nil var sender: String? = nil
@@ -80,14 +80,14 @@ struct MsgContentView: View {
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { 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) -> Text { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
let s = text let s = text
var res: Text var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 { if let ft = formattedText, ft.count > 0 {
res = formatText(ft[0], preview) res = formatText(ft[0], preview)
var i = 1 var i = 1
while i < ft.count { while i < ft.count {
@@ -121,11 +121,13 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor) case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "") case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, smpHosts): case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
switch privacySimplexLinkModeDefault.get() { switch privacySimplexLinkModeDefault.get() {
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
case .full: return linkText(t, 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 .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
@@ -152,7 +154,6 @@ struct MsgContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return MsgContentView( return MsgContentView(
chat: Chat.sampleData,
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
@@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatItemInfoView: View { struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var ci: ChatItem var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo? @Binding var chatItemInfo: ChatItemInfo?
@@ -25,11 +24,11 @@ struct ChatItemInfoView: View {
} }
enum CIInfoViewAlert: Identifiable { enum CIInfoViewAlert: Identifiable {
case alert(title: String, text: String) case deliveryStatusAlert(status: CIStatus)
var id: String { var id: String {
switch self { switch self {
case let .alert(title, text): return "alert \(title) \(text)" case .deliveryStatusAlert: return "deliveryStatusAlert"
} }
} }
} }
@@ -44,9 +43,9 @@ struct ChatItemInfoView: View {
} }
} }
} }
.alert(item: $alert) { a in .alert(item: $alert) { alertItem in
switch(a) { switch(alertItem) {
case let .alert(title, text): return Alert(title: Text(title), message: Text(text)) case let .deliveryStatusAlert(status): return deliveryStatusAlert(status)
} }
} }
} }
@@ -283,7 +282,7 @@ struct ChatItemInfoView: View {
memberDeliveryStatusView(memberStatus.0, memberStatus.1) memberDeliveryStatusView(memberStatus.0, memberStatus.1)
} }
} else { } else {
Text("No delivery information") Text("No info on delivery")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
@@ -291,8 +290,8 @@ struct ChatItemInfoView: View {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
memberDeliveryStatuses.compactMap({ mds in memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) { if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
return (mem.wrapped, mds.memberDeliveryStatus) return (mem, mds.memberDeliveryStatus)
} else { } else {
return nil return nil
} }
@@ -307,7 +306,7 @@ struct ChatItemInfoView: View {
Text(member.chatViewName) Text(member.chatViewName)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
let v = Group { Group {
if let (icon, statusColor) = status.statusIcon(Color.secondary) { if let (icon, statusColor) = status.statusIcon(Color.secondary) {
switch status { switch status {
case .sndRcvd: case .sndRcvd:
@@ -327,17 +326,19 @@ struct ChatItemInfoView: View {
.foregroundColor(Color.secondary) .foregroundColor(Color.secondary)
} }
} }
.onTapGesture {
if let (title, text) = status.statusInfo { alert = .deliveryStatusAlert(status: status)
v.onTapGesture {
alert = .alert(title: title, text: text)
}
} else {
v
} }
} }
} }
func deliveryStatusAlert(_ status: CIStatus) -> Alert {
Alert(
title: Text(status.statusText),
message: Text(status.statusDescription)
)
}
private func itemInfoShareText() -> String { private func itemInfoShareText() -> String {
let meta = ci.meta let meta = ci.meta
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""] var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
@@ -383,6 +384,20 @@ struct ChatItemInfoView: View {
} }
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")] shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
} }
if let mdss = chatItemInfo?.memberDeliveryStatuses {
let mss = membersStatuses(mdss)
if !mss.isEmpty {
shareText += ["", NSLocalizedString("## Delivery", comment: "copied message info")]
shareText += [""]
for (member, status) in mss {
shareText += [String.localizedStringWithFormat(
NSLocalizedString("%@: %@", comment: "copied message info, <recipient>: <message delivery status description>"),
member.chatViewName,
status.statusDescription
)]
}
}
}
if let chatItemInfo = chatItemInfo, if let chatItemInfo = chatItemInfo,
!chatItemInfo.itemVersions.isEmpty { !chatItemInfo.itemVersions.isEmpty {
shareText += ["", NSLocalizedString("## History", comment: "copied message info")] shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
+36 -106
View File
@@ -10,8 +10,9 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat var chatInfo: ChatInfo
var chatItem: ChatItem var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil @State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool @Binding var revealed: Bool
@@ -19,20 +20,10 @@ struct ChatItemView: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
init( 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)) {
chat: Chat, self.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.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.showMember = showMember
self.maxWidth = maxWidth self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy) _scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed _revealed = revealed
@@ -44,15 +35,15 @@ struct ChatItemView: View {
var body: some View { var body: some View {
let ci = chatItem let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { if chatItem.meta.itemDeleted != nil && !revealed {
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci) EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, 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 { } 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 { } else {
framedItemView() framedItemView()
} }
@@ -62,17 +53,15 @@ struct ChatItemView: View {
} }
private func framedItemView() -> some 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 { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel var chatInfo: ChatInfo
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool var showMember: Bool
var msgContentView: () -> Content var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var body: some View { var body: some View {
switch chatItem.content { switch chatItem.content {
@@ -82,17 +71,10 @@ struct ChatItemContentView<Content: View>: View {
case .rcvDeleted: deletedItemView() case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration) case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration) case let .rcvCall(status, duration): callItemView(status, duration)
case let .rcvIntegrityError(msgError): case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
if developerTools { case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
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 .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(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 .rcvGroupEvent: eventItemView()
case .sndGroupEvent: eventItemView() case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView() case .rcvConnEvent: eventItemView()
@@ -100,9 +82,9 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param): 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, _, _): 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 .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(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 .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -114,90 +96,38 @@ struct ChatItemContentView<Content: View>: View {
} }
private func deletedItemView() -> some View { private func deletedItemView() -> some View {
DeletedItemView(chat: chat, chatItem: chatItem) DeletedItemView(chatItem: chatItem, showMember: showMember)
} }
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { 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 { 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 { private func eventItemView() -> some View {
return CIEventView(eventText: eventItemViewText()) CIEventView(chatItem: chatItem)
}
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)
}
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { 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 { struct ChatItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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(chatInfo: ChatInfo.sampleData.direct, 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", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
} }
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
@@ -209,7 +139,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{ Group{
ChatItemView( ChatItemView(
chat: Chat.sampleData, chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -220,7 +150,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@@ -231,7 +161,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -242,7 +172,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -253,7 +183,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
+172 -382
View File
@@ -21,7 +21,9 @@ struct ChatView: View {
@State private var showChatInfoSheet: Bool = false @State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@State private var keyboardVisible = false @State private var keyboardVisible = false
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile? @State private var customUserProfile: Profile?
@State private var connectionCode: String? @State private var connectionCode: String?
@@ -34,12 +36,7 @@ struct ChatView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon // opening GroupMemberInfoView on member icon
@State private var membersLoaded = false @State private var selectedMember: GroupMember? = nil
@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
var body: some View { var body: some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@@ -67,7 +64,6 @@ struct ChatView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
connectingText()
ComposeView( ComposeView(
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
@@ -94,10 +90,7 @@ struct ChatView: View {
chatModel.chatId = nil chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil { if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
chatModel.reversedChatItems = [] chatModel.reversedChatItems = []
chatModel.groupMembers = []
membersLoaded = false
} }
} }
} }
@@ -114,7 +107,7 @@ struct ChatView: View {
connectionStats = stats connectionStats = stats
customUserProfile = profile customUserProfile = profile
connectionCode = code connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode { if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
chat.chatInfo = .direct(contact: ct) chat.chatInfo = .direct(contact: ct)
} }
} }
@@ -135,21 +128,18 @@ struct ChatView: View {
} }
} else if case let .group(groupInfo) = cInfo { } else if case let .group(groupInfo) = cInfo {
Button { Button {
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } } Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showChatInfoSheet = true
}
}
} label: { } label: {
ChatInfoToolbar(chat: chat) ChatInfoToolbar(chat: chat)
} }
.appSheet(isPresented: $showChatInfoSheet) { .appSheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView( GroupChatInfoView(chat: chat, groupInfo: groupInfo)
chat: chat,
groupInfo: Binding(
get: { groupInfo },
set: { gInfo in
chat.chatInfo = .group(groupInfo: gInfo)
chat.created = Date.now
}
)
)
} }
} }
} }
@@ -159,7 +149,6 @@ struct ChatView: View {
HStack { HStack {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
callButton(contact, .audio, imageName: "phone") callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} }
Menu { Menu {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
@@ -168,11 +157,9 @@ struct ChatView: View {
} label: { } label: {
Label("Video call", systemImage: "video") Label("Video call", systemImage: "video")
} }
.disabled(!contact.ready || !contact.active)
} }
searchButton() searchButton()
toggleNtfsButton(chat) toggleNtfsButton(chat)
.disabled(!contact.ready || !contact.active)
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
} }
@@ -181,16 +168,9 @@ struct ChatView: View {
HStack { HStack {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) { if (chat.chatInfo.incognito) {
groupLinkButton() Image(systemName: "person.crop.circle.badge.plus")
.appSheet(isPresented: $showGroupLinkSheet) { .foregroundColor(Color(uiColor: .tertiaryLabel))
GroupLinkView( .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else { } else {
addMembersButton() addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) { .appSheet(isPresented: $showAddMembersSheet) {
@@ -212,17 +192,6 @@ 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() { private func initChatView() {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
if case let .direct(contact) = cInfo { if case let .direct(contact) = cInfo {
@@ -292,7 +261,7 @@ struct ChatView: View {
return GeometryReader { g in return GeometryReader { g in
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
let voiceNoFrame = voiceWithoutFrame(ci) let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group let maxWidth = cInfo.chatType == .group
@@ -344,20 +313,6 @@ struct ChatView: View {
} }
.scaleEffect(x: 1, y: -1, anchor: .center) .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 { private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView) let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
@@ -431,32 +386,19 @@ struct ChatView: View {
private func addMembersButton() -> some View { private func addMembersButton() -> some View {
Button { Button {
if case let .group(gInfo) = chat.chatInfo { 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: { } label: {
Image(systemName: "person.crop.circle.badge.plus") 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) { private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return } if loadingItems || firstPage { return }
@@ -486,149 +428,86 @@ struct ChatView: View {
} }
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu( if case let .groupRcv(member) = ci.chatDir,
chat: chat, case let .group(groupInfo) = chat.chatInfo {
chatItem: ci, let prevItem = chatModel.getPrevChatItem(ci)
maxWidth: maxWidth, HStack(alignment: .top, spacing: 0) {
composeState: $composeState, let showMember = prevItem == nil || showMemberImage(member, prevItem)
selectedMember: $selectedMember, if showMember {
chatView: self 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 { private struct ChatItemWithMenu: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat var ci: ChatItem
var chatItem: ChatItem var showMember: Bool = false
var maxWidth: CGFloat var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var selectedMember: GMember? @Binding var showDeleteMessage: Bool
var chatView: ChatView
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@State private var deletingItems: [Int64] = []
@State private var showDeleteMessages = false
@State private var revealed = false @State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var audioPlayer: AudioPlayer? @State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback @State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval? @State private var playbackTime: TimeInterval?
var body: some View { 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 alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding( 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 } set: { _ in }
) )
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView( ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
chat: chat, .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
chatItem: ci,
maxWidth: maxWidth,
scrollProxy: chatView.scrollProxy,
revealed: $revealed,
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions(ci) chatItemReactions()
.padding(.bottom, 4) .padding(.bottom, 4)
} }
} }
@@ -642,11 +521,6 @@ struct ChatView: View {
} }
} }
} }
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages()
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear { .onDisappear {
@@ -664,15 +538,7 @@ struct ChatView: View {
} }
} }
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { private func chatItemReactions() -> some View {
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 {
HStack(spacing: 4) { HStack(spacing: 4) {
ForEach(ci.reactions, id: \.reaction) { r in ForEach(ci.reactions, id: \.reaction) { r in
let v = HStack(spacing: 4) { let v = HStack(spacing: 4) {
@@ -692,7 +558,7 @@ struct ChatView: View {
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
v.onTapGesture { v.onTapGesture {
setReaction(ci, add: !r.userReacted, reaction: r.reaction) setReaction(add: !r.userReacted, reaction: r.reaction)
} }
} else { } else {
v v
@@ -701,10 +567,10 @@ struct ChatView: View {
} }
} }
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> [UIMenuElement] { private func menu(live: Bool) -> [UIMenuElement] {
var menu: [UIMenuElement] = [] var menu: [UIMenuElement] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { 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, if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
rs.count > 0 { rs.count > 0 {
var rm: UIMenu var rm: UIMenu
@@ -721,25 +587,25 @@ struct ChatView: View {
menu.append(rm) menu.append(rm)
} }
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction(ci)) menu.append(replyUIAction())
} }
menu.append(shareUIAction(ci)) menu.append(shareUIAction())
menu.append(copyUIAction(ci)) menu.append(copyUIAction())
if let fileSource = getLoadedFileSource(ci.file) { if let filePath = getLoadedFilePath(ci.file) {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil { if image.imageData != nil {
menu.append(saveFileAction(fileSource)) menu.append(saveFileAction(filePath))
} else { } else {
menu.append(saveImageAction(image)) menu.append(saveImageAction(image))
} }
} else { } else {
menu.append(saveFileAction(fileSource)) menu.append(saveFileAction(filePath))
} }
} }
if ci.meta.editable && !mc.isVoice && !live { if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction(ci)) menu.append(editAction())
} }
menu.append(viewInfoUIAction(ci)) menu.append(viewInfoUIAction())
if revealed { if revealed {
menu.append(hideUIAction()) menu.append(hideUIAction())
} }
@@ -749,31 +615,25 @@ struct ChatView: View {
menu.append(cancelFileUIAction(file.fileId, cancelAction)) menu.append(cancelFileUIAction(file.fileId, cancelAction))
} }
if !live || !ci.meta.isLive { if !live || !ci.meta.isLive {
menu.append(deleteUIAction(ci)) menu.append(deleteUIAction())
} }
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
menu.append(moderateUIAction(ci, groupInfo)) menu.append(moderateUIAction(groupInfo))
} }
} else if ci.meta.itemDeleted != nil { } else if ci.meta.itemDeleted != nil {
if revealed { if !ci.isDeletedContent {
menu.append(hideUIAction())
} else if !ci.isDeletedContent {
menu.append(revealUIAction()) menu.append(revealUIAction())
} else if range != nil {
menu.append(expandUIAction())
} }
menu.append(viewInfoUIAction(ci)) menu.append(viewInfoUIAction())
menu.append(deleteUIAction(ci)) menu.append(deleteUIAction())
} else if ci.isDeletedContent { } else if ci.isDeletedContent {
menu.append(viewInfoUIAction(ci)) menu.append(viewInfoUIAction())
menu.append(deleteUIAction(ci)) menu.append(deleteUIAction())
} else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) {
menu.append(revealed ? shrinkUIAction() : expandUIAction())
} }
return menu return menu
} }
private func replyUIAction(_ ci: ChatItem) -> UIAction { private func replyUIAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"), title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left") image: UIImage(systemName: "arrowshape.turn.up.left")
@@ -808,11 +668,11 @@ struct ChatView: View {
) )
} }
private func allReactions(_ ci: ChatItem) -> [UIAction] { private func allReactions() -> [UIAction] {
MsgReaction.values.compactMap { r in MsgReaction.values.compactMap { r in
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
? nil ? nil
: UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) } : UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
} }
} }
@@ -820,7 +680,7 @@ struct ChatView: View {
rs.count > 4 ? 3 : 4 rs.count > 4 ? 3 : 4
} }
private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) { private func setReaction(add: Bool, reaction: MsgReaction) {
Task { Task {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
@@ -832,7 +692,7 @@ struct ChatView: View {
reaction: reaction reaction: reaction
) )
await MainActor.run { await MainActor.run {
m.updateChatItem(chat.chatInfo, chatItem) ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
} }
} catch let error { } catch let error {
logger.error("apiChatItemReaction error: \(responseError(error))") logger.error("apiChatItemReaction error: \(responseError(error))")
@@ -840,7 +700,7 @@ struct ChatView: View {
} }
} }
private func shareUIAction(_ ci: ChatItem) -> UIAction { private func shareUIAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Share", comment: "chat item action"), title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up") image: UIImage(systemName: "square.and.arrow.up")
@@ -853,7 +713,7 @@ struct ChatView: View {
} }
} }
private func copyUIAction(_ ci: ChatItem) -> UIAction { private func copyUIAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"), title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc") image: UIImage(systemName: "doc.on.doc")
@@ -877,16 +737,17 @@ struct ChatView: View {
} }
} }
private func saveFileAction(_ fileSource: CryptoFile) -> UIAction { private func saveFileAction(_ filePath: String) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Save", comment: "chat item action"), 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 ) { _ in
saveCryptoFile(fileSource) let fileURL = URL(fileURLWithPath: filePath)
showShareSheet(items: [fileURL])
} }
} }
private func editAction(_ ci: ChatItem) -> UIAction { private func editAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"), title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil") image: UIImage(systemName: "square.and.pencil")
@@ -897,7 +758,7 @@ struct ChatView: View {
} }
} }
private func viewInfoUIAction(_ ci: ChatItem) -> UIAction { private func viewInfoUIAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Info", comment: "chat item action"), title: NSLocalizedString("Info", comment: "chat item action"),
image: UIImage(systemName: "info.circle") image: UIImage(systemName: "info.circle")
@@ -910,7 +771,10 @@ struct ChatView: View {
chatItemInfo = ciInfo chatItemInfo = ciInfo
} }
if case let .group(gInfo) = chat.chatInfo { if case let .group(gInfo) = chat.chatInfo {
await chatView.loadGroupMembers(gInfo) let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
}
} }
} catch let error { } catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))") logger.error("apiGetChatItemInfo error: \(responseError(error))")
@@ -931,7 +795,7 @@ struct ChatView: View {
message: Text(cancelAction.alert.message), message: Text(cancelAction.alert.message),
primaryButton: .destructive(Text(cancelAction.alert.confirm)) { primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
Task { Task {
if let user = m.currentUser { if let user = ChatModel.shared.currentUser {
await cancelFile(user: user, fileId: fileId) await cancelFile(user: user, fileId: fileId)
} }
} }
@@ -952,45 +816,18 @@ struct ChatView: View {
} }
} }
private func deleteUIAction(_ ci: ChatItem) -> UIAction { private func deleteUIAction() -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"), title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"), image: UIImage(systemName: "trash"),
attributes: [.destructive] attributes: [.destructive]
) { _ in ) { _ in
if !revealed && ci.meta.itemDeleted != nil, showDeleteMessage = true
let currIndex = m.getChatItemIndex(ci), deletingItem = 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
}
} }
} }
private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange<Int>? { private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction {
if let currIndex = currIndex,
let prevHidden = prevHidden,
prevHidden > currIndex {
currIndex...prevHidden
} else {
nil
}
}
private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Moderate", comment: "chat item action"), title: NSLocalizedString("Moderate", comment: "chat item action"),
image: UIImage(systemName: "flag"), image: UIImage(systemName: "flag"),
@@ -1022,105 +859,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 { private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" 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) { private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first { if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
@@ -1132,6 +884,44 @@ struct ChatView: View {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } 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 { @ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
@@ -1148,7 +938,7 @@ struct ChatView: View {
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.enableNtfs = enableNtfs ? .all : .none chatSettings.enableNtfs = enableNtfs
updateChatSettings(chat, chatSettings: chatSettings) updateChatSettings(chat, chatSettings: chatSettings)
} }
@@ -44,6 +44,7 @@ struct ComposeState {
var contextItem: ComposeContextItem var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false var inProgress = false
var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
init( init(
@@ -167,23 +168,25 @@ struct ComposeState {
} }
func chatItemPreview(chatItem: ChatItem) -> ComposePreview { func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
let chatItemPreview: ComposePreview
switch chatItem.content.msgContent { switch chatItem.content.msgContent {
case .text: case .text:
return .noPreview chatItemPreview = .noPreview
case let .link(_, preview: preview): case let .link(_, preview: preview):
return .linkPreview(linkPreview: preview) chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image): case let .image(_, image):
return .mediaPreviews(mediaPreviews: [(image, nil)]) chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
case let .video(_, image, _): case let .video(_, image, _):
return .mediaPreviews(mediaPreviews: [(image, nil)]) chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
case let .voice(_, duration): case let .voice(_, duration):
return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
case .file: case .file:
let fileName = chatItem.file?.fileName ?? "" let fileName = chatItem.file?.fileName ?? ""
return .filePreview(fileName: fileName, file: getAppFilePath(fileName)) chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName))
default: default:
return .noPreview chatItemPreview = .noPreview
} }
return chatItemPreview
} }
enum UploadContent: Equatable { enum UploadContent: Equatable {
@@ -238,7 +241,6 @@ struct ComposeView: View {
@State var pendingLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set<String> = [] @State var cancelledLinks: Set<String> = []
@Environment(\.colorScheme) private var colorScheme
@State private var showChooseSource = false @State private var showChooseSource = false
@State private var showMediaPicker = false @State private var showMediaPicker = false
@State private var showTakePhoto = 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 // this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView()
}
contextItemView() contextItemView()
switch (composeState.editing, composeState.preview) { switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView() case (true, .filePreview): EmptyView()
@@ -273,7 +270,7 @@ struct ComposeView: View {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.resizable() .resizable()
} }
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .disabled(composeState.attachmentDisabled || !chat.userCanSend)
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 12) .padding(.bottom, 12)
.padding(.leading, 12) .padding(.leading, 12)
@@ -301,7 +298,6 @@ struct ComposeView: View {
composeState.liveMessage = nil composeState.liveMessage = nil
chatModel.removeLiveDummy() chatModel.removeLiveDummy()
}, },
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: { startVoiceMessageRecording: {
@@ -313,10 +309,7 @@ struct ComposeView: View {
allowVoiceMessagesToContact: allowVoiceMessagesToContact, allowVoiceMessagesToContact: allowVoiceMessagesToContact,
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible
sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: .accentColor
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.background(.background) .background(.background)
@@ -449,15 +442,7 @@ struct ComposeView: View {
} else if (composeState.inProgress) { } else if (composeState.inProgress) {
clearCurrentDraft() clearCurrentDraft()
} else if !composeState.empty { } else if !composeState.empty {
if case .recording = composeState.voiceMessageRecordingState { saveCurrentDraft()
finishVoiceMessageRecording()
if let fileName = composeState.voiceMessageRecordingFileName {
chatModel.filesToDelete.insert(getAppFilePath(fileName))
}
}
if saveLastDraft {
saveCurrentDraft()
}
} else { } else {
cancelCurrentVoiceRecording() cancelCurrentVoiceRecording()
clearCurrentDraft() clearCurrentDraft()
@@ -592,14 +577,12 @@ struct ComposeView: View {
EmptyView() EmptyView()
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat,
contextItem: quotedItem, contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat,
contextItem: editingItem, contextItem: editingItem,
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
@@ -623,9 +606,7 @@ struct ComposeView: View {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) } if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending() await sending()
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if case let .editingItem(ci) = composeState.contextItem {
await sendMemberContactInvitation()
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live) sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live) sent = await updateMessage(liveMessage.chatItem, live: live)
@@ -662,10 +643,10 @@ struct ComposeView: View {
} }
case let .voicePreview(recordingFileName, duration): case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle() stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName) chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
case let .filePreview(_, file): case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) { if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
} }
@@ -674,19 +655,9 @@ struct ComposeView: View {
return sent return sent
func sending() async { func sending() async {
await MainActor.run { composeState.inProgress = true } await MainActor.run { composeState.disabled = true }
} DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if composeState.disabled { 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))")
} }
} }
@@ -746,28 +717,13 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData 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 await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
return nil return nil
} }
func voiceCryptoFile(_ fileName: String) -> CryptoFile? { func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
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 = await apiSendMessage( if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
@@ -784,7 +740,7 @@ struct ComposeView: View {
return chatItem return chatItem
} }
if let file = file { if let file = file {
removeFile(file.filePath) removeFile(file)
} }
return nil return nil
} }
@@ -804,7 +760,7 @@ struct ComposeView: View {
} }
} }
func saveAnyImage(_ img: UploadContent) -> CryptoFile? { func saveAnyImage(_ img: UploadContent) -> String? {
switch img { switch img {
case let .simpleImage(image): return saveImage(image) case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image) case let .animatedImage(image): return saveAnimImage(image)
@@ -896,6 +852,7 @@ struct ComposeView: View {
private func clearState(live: Bool = false) { private func clearState(live: Bool = false) {
if live { if live {
composeState.disabled = false
composeState.inProgress = false composeState.inProgress = false
} else { } else {
composeState = ComposeState() composeState = ComposeState()
@@ -908,6 +865,12 @@ struct ComposeView: View {
} }
private func saveCurrentDraft() { private func saveCurrentDraft() {
if case .recording = composeState.voiceMessageRecordingState {
finishVoiceMessageRecording()
if let fileName = composeState.voiceMessageRecordingFileName {
chatModel.filesToDelete.insert(getAppFilePath(fileName))
}
}
chatModel.draft = composeState chatModel.draft = composeState
chatModel.draftChatId = chat.id chatModel.draftChatId = chat.id
} }
@@ -188,7 +188,7 @@ struct ComposeVoiceView: View {
playbackTime = recordingTime // animate progress bar to the end playbackTime = recordingTime // animate progress bar to the end
} }
) )
audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime) audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing 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 { struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
let contextItem: ChatItem let contextItem: ChatItem
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
@@ -23,14 +22,13 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.foregroundColor(.secondary) .foregroundColor(.secondary)
if let sender = contextItem.memberDisplayName { MsgContentView(
VStack(alignment: .leading, spacing: 4) { text: contextItem.text,
Text(sender).font(.caption).foregroundColor(.secondary) formattedText: contextItem.formattedText,
msgContentView(lines: 2) sender: contextItem.memberDisplayName
} )
} else { .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
msgContentView(lines: 3) .lineLimit(3)
}
Spacer() Spacer()
Button { Button {
withAnimation { withAnimation {
@@ -46,21 +44,11 @@ struct ContextItemView: View {
.background(chatItemFrameColor(contextItem, colorScheme)) .background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8) .padding(.top, 8)
} }
private func msgContentView(lines: Int) -> some View {
MsgContentView(
chat: chat,
text: contextItem.text,
formattedText: contextItem.formattedText
)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines)
}
} }
struct ContextItemView_Previews: PreviewProvider { struct ContextItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
} }
} }
@@ -14,28 +14,20 @@ import PhotosUI
struct NativeTextEditor: UIViewRepresentable { struct NativeTextEditor: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@Binding var disableEditing: Bool @Binding var disableEditing: Bool
@Binding var height: CGFloat let height: CGFloat
let font: UIFont
@Binding var focused: Bool @Binding var focused: Bool
let alignment: TextAlignment let alignment: TextAlignment
let onImagesAdded: ([UploadContent]) -> Void 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 { func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField(height: _height) let field = CustomUITextField()
field.text = text field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right field.textAlignment = alignment == .leading ? .left : .right
field.autocapitalizationType = .sentences field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in field.setOnTextChangedListener { newText, images in
if !disableEditing { if !disableEditing {
// Speed up the process of updating layout, reduce jumping content on screen
if !isShortEmoji(newText) { updateHeight(field) }
text = newText text = newText
} else { } else {
field.text = text field.text = text
@@ -47,72 +39,24 @@ struct NativeTextEditor: UIViewRepresentable {
field.setOnFocusChangedListener { focused = $0 } field.setOnFocusChangedListener { focused = $0 }
field.delegate = field field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
updateFont(field)
updateHeight(field)
return field return field
} }
func updateUIView(_ field: UITextView, context: Context) { func updateUIView(_ field: UITextView, context: Context) {
field.text = text field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right 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 { private class CustomUITextField: UITextView, UITextViewDelegate {
var height: Binding<CGFloat>
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in } var onFocusChanged: (Bool) -> Void = { focused in }
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) { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged self.onTextChanged = onTextChanged
} }
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged self.onFocusChanged = onFocusChanged
} }
@@ -200,14 +144,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
struct NativeTextEditor_Previews: PreviewProvider{ struct NativeTextEditor_Previews: PreviewProvider{
static var previews: some View { static var previews: some View {
NativeTextEditor( return NativeTextEditor(
text: Binding.constant("Hello, world!"), text: Binding.constant("Hello, world!"),
disableEditing: Binding.constant(false), disableEditing: Binding.constant(false),
height: Binding.constant(100), height: 100,
font: UIFont.preferredFont(forTextStyle: .body),
focused: Binding.constant(false), focused: Binding.constant(false),
alignment: TextAlignment.leading, alignment: TextAlignment.leading,
onImagesAdded: { _ in } onImagesAdded: { _ in }
) )
.fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -17,7 +17,6 @@ struct SendMessageView: View {
var sendLiveMessage: (() async -> Void)? = nil var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil var cancelLiveMessage: (() -> Void)? = nil
var nextSendGrpInv: Bool = false
var showVoiceMessageButton: Bool = true var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true var voiceMessageAllowed: Bool = true
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
@@ -29,15 +28,16 @@ struct SendMessageView: View {
@State private var holdingVMR = false @State private var holdingVMR = false
@Namespace var namespace @Namespace var namespace
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42 @State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body @State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@State private var sendButtonSize: CGFloat = 29 @State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1 @State private var sendButtonOpacity: CGFloat = 1
@State private var showCustomDisappearingMessageDialogue = false @State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false @State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View { var body: some View {
@@ -54,20 +54,34 @@ struct SendMessageView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading 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( NativeTextEditor(
text: $composeState.message, text: $composeState.message,
disableEditing: $composeState.inProgress, disableEditing: $composeState.inProgress,
height: $teHeight, height: teHeight,
font: teUiFont,
focused: $keyboardVisible, focused: $keyboardVisible,
alignment: alignment, alignment: alignment,
onImagesAdded: onMediaAdded onImagesAdded: onMediaAdded
) )
.allowsTightening(false) .allowsTightening(false)
.fixedSize(horizontal: false, vertical: true) .frame(height: teHeight)
} }
} }
if progressByTimeout { if composeState.inProgress {
ProgressView() ProgressView()
.scaleEffect(1.4) .scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center) .frame(width: 31, height: 31, alignment: .center)
@@ -83,30 +97,17 @@ struct SendMessageView: View {
.frame(height: teHeight, alignment: .bottom) .frame(height: teHeight, alignment: .bottom)
} }
} }
.padding(.vertical, 1)
.overlay(
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
) .frame(height: teHeight)
}
.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
}
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
@ViewBuilder private func composeActionButtons() -> some View { @ViewBuilder private func composeActionButtons() -> some View {
let vmrs = composeState.voiceMessageRecordingState let vmrs = composeState.voiceMessageRecordingState
if nextSendGrpInv { if showVoiceMessageButton
inviteMemberContactButton()
} else if showVoiceMessageButton
&& composeState.message.isEmpty && composeState.message.isEmpty
&& !composeState.editing && !composeState.editing
&& composeState.liveMessage == nil && composeState.liveMessage == nil
@@ -118,7 +119,7 @@ struct SendMessageView: View {
startVoiceMessageRecording: startVoiceMessageRecording, startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording, finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR, holdingVMR: $holdingVMR,
disabled: composeState.inProgress disabled: composeState.disabled
) )
} else { } else {
voiceMessageNotAllowedButton() voiceMessageNotAllowedButton()
@@ -150,24 +151,6 @@ struct SendMessageView: View {
.padding([.top, .trailing], 4) .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 { private func sendMessageButton() -> some View {
Button { Button {
sendMessage(nil) sendMessage(nil)
@@ -176,13 +159,13 @@ struct SendMessageView: View {
? "checkmark.circle.fill" ? "checkmark.circle.fill"
: "arrow.up.circle.fill") : "arrow.up.circle.fill")
.resizable() .resizable()
.foregroundColor(sendButtonColor) .foregroundColor(.accentColor)
.frame(width: sendButtonSize, height: sendButtonSize) .frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity) .opacity(sendButtonOpacity)
} }
.disabled( .disabled(
!composeState.sendEnabled || !composeState.sendEnabled ||
composeState.inProgress || composeState.disabled ||
(!voiceMessageAllowed && composeState.voicePreview) || (!voiceMessageAllowed && composeState.voicePreview) ||
composeState.endLiveDisabled composeState.endLiveDisabled
) )
@@ -310,7 +293,7 @@ struct SendMessageView: View {
Image(systemName: "mic") Image(systemName: "mic")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.disabled(composeState.inProgress) .disabled(composeState.disabled)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
@@ -395,17 +378,21 @@ struct SendMessageView: View {
Image(systemName: "stop.fill") Image(systemName: "stop.fill")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
} }
.disabled(composeState.inProgress) .disabled(composeState.disabled)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
private func updateFont(_ text: String) { private func updateHeight(_ g: GeometryProxy) -> Color {
DispatchQueue.main.async { DispatchQueue.main.async {
teFont = isShortEmoji(text) teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
? (text.count < 4 ? largeEmojiFont : mediumEmojiFont) (teFont, teUiFont) = isShortEmoji(composeState.message)
: .body ? composeState.message.count < 4
? (largeEmojiFont, largeEmojiUIFont)
: (mediumEmojiFont, mediumEmojiUIFont)
: (.body, UIFont.preferredFont(forTextStyle: .body))
} }
return Color.clear
} }
} }
@@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View {
do { do {
for contactId in selectedContacts { for contactId in selectedContacts {
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) 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) addedMembersCb(selectedContacts)
} catch { } catch {
@@ -157,7 +157,7 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View { private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) { Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.allCases) { role in ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole && role != .author { if role <= groupInfo.membership.memberRole {
Text(role.text) Text(role.text)
} }
} }
@@ -15,7 +15,7 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @State var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared @ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@@ -35,30 +35,14 @@ struct GroupChatInfoView: View {
case leaveGroupAlert case leaveGroupAlert
case cantInviteIncognitoAlert case cantInviteIncognitoAlert
case largeGroupReceiptsDisabled case largeGroupReceiptsDisabled
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: GroupChatInfoViewAlert { get { self } }
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 .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)"
}
}
} }
var body: some View { var body: some View {
NavigationView { NavigationView {
let members = chatModel.groupMembers 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() } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
List { List {
@@ -73,7 +57,7 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage() addOrEditWelcomeMessage()
} }
groupPreferencesButton($groupInfo) groupPreferencesButton($groupInfo)
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption() sendReceiptsOption()
} else { } else {
sendReceiptsOptionDisabled() sendReceiptsOptionDisabled()
@@ -100,17 +84,17 @@ struct GroupChatInfoView: View {
.padding(.leading, 8) .padding(.leading, 8)
} }
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) }
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) memberView(groupInfo.membership, user: true)
ForEach(filteredMembers) { member in ForEach(filteredMembers) { member in
ZStack { ZStack {
NavigationLink { NavigationLink {
memberInfoView(member) memberInfoView(member.groupMemberId)
} label: { } label: {
EmptyView() EmptyView()
} }
.opacity(0) .opacity(0)
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) memberView(member)
} }
} }
} }
@@ -142,10 +126,6 @@ struct GroupChatInfoView: View {
case .leaveGroupAlert: return leaveGroupAlert() case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@@ -194,7 +174,7 @@ struct GroupChatInfoView: View {
Task { Task {
let groupMembers = await apiListMembers(groupInfo.groupId) let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run { await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) } ChatModel.shared.groupMembers = groupMembers
} }
} }
} }
@@ -203,92 +183,51 @@ struct GroupChatInfoView: View {
} }
} }
private struct MemberRowView: View { private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
var groupInfo: GroupInfo HStack{
@ObservedObject var groupMember: GMember ProfileImage(imageStr: member.image)
var user: Bool = false .frame(width: 38, height: 38)
@Binding var alert: GroupChatInfoViewAlert? .padding(.trailing, 2)
// TODO server connection status
var body: some View { VStack(alignment: .leading) {
let member = groupMember.wrapped let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
let v = HStack{ (member.verified ? memberVerifiedShield + t : t)
ProfileImage(imageStr: member.image) .lineLimit(1)
.frame(width: 38, height: 38) let s = Text(member.memberStatus.shortText)
.padding(.trailing, 2) (user ? Text ("you: ") + s : s)
// TODO server connection status .lineLimit(1)
VStack(alignment: .leading) { .font(.caption)
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) .foregroundColor(.secondary)
(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()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
.foregroundColor(.secondary)
}
} }
Spacer()
if user { let role = member.memberRole
v if role == .owner || role == .admin {
} else if member.canBeRemoved(groupInfo: groupInfo) { Text(member.memberRole.text)
removeSwipe(member, blockSwipe(member, v)) .foregroundColor(.secondary)
} else {
blockSwipe(member, v)
}
}
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 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)
}
} }
} }
} }
private func memberInfoView(_ groupMember: GMember) -> some View { private var memberVerifiedShield: Text {
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.navigationBarHidden(false) .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 { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
GroupLinkView( GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
groupId: groupInfo.groupId, .navigationBarTitle("Group link")
groupLink: $groupLink, .navigationBarTitleDisplayMode(.large)
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
if groupLink == nil { if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus") Label("Create group link", systemImage: "link.badge.plus")
@@ -360,9 +299,9 @@ struct GroupChatInfoView: View {
do { do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run { await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id) chatModel.removeChat(chat.chatInfo.id)
chatModel.chatId = nil
dismiss()
} }
} catch let error { } catch let error {
logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)") logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)")
@@ -436,28 +375,6 @@ struct GroupChatInfoView: View {
alert = .largeGroupReceiptsDisabled 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 { func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
@@ -479,14 +396,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 { func cantInviteIncognitoAlert() -> Alert {
Alert( Alert(
title: Text("Can't invite contacts!"), title: Text("Can't invite contacts!"),
@@ -503,9 +412,6 @@ func largeGroupReceiptsDisabledAlert() -> Alert {
struct GroupChatInfoView_Previews: PreviewProvider { struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupChatInfoView( GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData)
)
} }
} }
@@ -13,9 +13,6 @@ struct GroupLinkView: View {
var groupId: Int64 var groupId: Int64
@Binding var groupLink: String? @Binding var groupLink: String?
@Binding var groupLinkMemberRole: GroupMemberRole @Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil
@State private var creatingLink = false @State private var creatingLink = false
@State private var alert: GroupLinkAlert? @State private var alert: GroupLinkAlert?
@@ -32,35 +29,10 @@ struct GroupLinkView: View {
} }
var body: some View { var body: some View {
if creatingGroup {
NavigationView {
groupLinkView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() }
}
}
}
} else {
groupLinkView()
}
}
private func groupLinkView() -> some View {
List { List {
Group { 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.")
if showTitle { .listRowBackground(Color.clear)
Text("Group link") .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.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))
Section { Section {
if let groupLink = groupLink { if let groupLink = groupLink {
Picker("Initial role", selection: $groupLinkMemberRole) { Picker("Initial role", selection: $groupLinkMemberRole) {
@@ -69,17 +41,15 @@ struct GroupLinkView: View {
} }
} }
.frame(height: 36) .frame(height: 36)
SimpleXLinkQRCode(uri: groupLink) QRCode(uri: groupLink)
Button { Button {
showShareSheet(items: [simplexChatLink(groupLink)]) showShareSheet(items: [groupLink])
} label: { } label: {
Label("Share link", systemImage: "square.and.arrow.up") Label("Share link", systemImage: "square.and.arrow.up")
} }
if !creatingGroup { Button(role: .destructive) { alert = .deleteLink } label: {
Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash")
Label("Delete link", systemImage: "trash")
}
} }
} else { } else {
Button(action: createGroupLink) { Button(action: createGroupLink) {
@@ -12,40 +12,36 @@ import SimpleXChat
struct GroupMemberInfoView: View { struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember @State var member: GroupMember
var navigation: Bool = false var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil @State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil @State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member @State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert? @State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true @State private var justOpened = true
@State private var progressIndicator = false
enum GroupMemberInfoViewAlert: Identifiable { enum GroupMemberInfoViewAlert: Identifiable {
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert case switchAddressAlert
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert) case connRequestSentAlert(type: ConnReqType)
case error(title: LocalizedStringKey, error: LocalizedStringKey) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case other(alert: Alert)
var id: String { var id: String {
switch self { switch self {
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" case .removeMemberAlert: return "removeMemberAlert"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .connRequestSentAlert: return "connRequestSentAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
case let .other(alert): return "other \(alert)"
} }
} }
} }
@@ -68,183 +64,169 @@ struct GroupMemberInfoView: View {
} }
private func groupMemberInfoView() -> some View { private func groupMemberInfoView() -> some View {
ZStack { VStack {
VStack { List {
let member = groupMember.wrapped groupMemberInfoHeader(member)
List { .listRowBackground(Color.clear)
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if member.memberActive { if member.memberActive {
Section { Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) { if let contactId = member.memberContactId {
if let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat) knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on { } else if groupInfo.fullGroupPreferences.directMessages.on {
if let contactId = member.memberContactId { newDirectChatButton(contactId)
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
} }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
}
if let contactLink = member.contactLink { if let contactLink = member.contactLink {
Section { Section {
SimpleXLinkQRCode(uri: contactLink) QRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [simplexChatLink(contactLink)]) showShareSheet(items: [contactLink])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
if let contactId = member.memberContactId { if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on { if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink) 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 { } 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 Section("Member") {
if let conn = member.activeConn { infoRow("Group", groupInfo.displayName)
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) 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 { // TODO invited by - need to get contact by contact id
Section("Servers") { if let conn = member.activeConn {
// TODO network connection status let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
Button("Change receiving address") { infoRow("Connection", connLevelDesc)
alert = .switchAddressAlert }
}
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 { $0.rcvSwitchStatus != nil } {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
} }
.disabled( .disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
}
if member.canBeRemoved(groupInfo: groupInfo) {
Section { Section {
if member.memberSettings.showMessages { removeMemberButton(member)
blockMemberButton(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
} }
}
if developerTools { if developerTools {
Section("For console") { Section("For console") {
infoRow("Local name", member.localDisplayName) infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)") infoRow("Database ID", "\(member.groupMemberId)")
}
} }
} }
.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)
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
justOpened = false
}
.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) .navigationBarHidden(true)
.alert(item: $alert) { alertItem in .onAppear {
switch(alertItem) { if #unavailable(iOS 16) {
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) // this condition prevents re-setting picker
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) if !justOpened { return }
case let .removeMemberAlert(mem): return removeMemberAlert(mem) }
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) newRole = member.memberRole
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) do {
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) member = mem
case let .error(title, error): return Alert(title: Text(title), message: Text(error)) 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) } }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
if progressIndicator { .alert(item: $alert) { alertItem in
ProgressView().scaleEffect(2) 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 .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
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 { func connectViaAddressButton(_ contactLink: String) -> some View {
Button { Button {
planAndConnect( connectViaAddress(contactLink)
contactLink,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
} label: { } label: {
Label("Connect", systemImage: "link") 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 { func knownDirectChatButton(_ chat: Chat) -> some View {
Button { Button {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
@@ -273,33 +255,6 @@ 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 { private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack { VStack {
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill)) ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
@@ -336,20 +291,20 @@ struct GroupMemberInfoView: View {
} }
private func verifyCodeButton(_ code: String) -> some View { private func verifyCodeButton(_ code: String) -> some View {
let member = groupMember.wrapped NavigationLink {
return NavigationLink {
VerifyCodeView( VerifyCodeView(
displayName: member.displayName, displayName: member.displayName,
connectionCode: code, connectionCode: code,
connectionVerified: member.verified, connectionVerified: member.verified,
verify: { code in verify: { code in
var member = groupMember.wrapped
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode connectionCode = existingCode
member.activeConn?.connectionCode = connCode 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 r
} }
return nil return nil
@@ -383,29 +338,12 @@ struct GroupMemberInfoView: View {
} }
} }
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 { private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) { Button(role: .destructive) {
alert = .removeMemberAlert(mem: mem) alert = .removeMemberAlert(mem: mem)
} label: { } label: {
Label("Remove member", systemImage: "trash") Label("Remove member", systemImage: "trash")
.foregroundColor(.red) .foregroundColor(Color.red)
} }
} }
@@ -441,6 +379,7 @@ struct GroupMemberInfoView: View {
do { do {
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
await MainActor.run { await MainActor.run {
member = updatedMember
_ = chatModel.upsertGroupMember(groupInfo, updatedMember) _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
} }
@@ -461,10 +400,10 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() { private func switchMemberAddress() {
Task { Task {
do { do {
let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
dismiss() dismiss()
} }
} catch let error { } catch let error {
@@ -480,10 +419,10 @@ struct GroupMemberInfoView: View {
private func abortSwitchMemberAddress() { private func abortSwitchMemberAddress() {
Task { Task {
do { do {
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
} }
} catch let error { } catch let error {
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
@@ -498,7 +437,7 @@ struct GroupMemberInfoView: View {
private func syncMemberConnection(force: Bool) { private func syncMemberConnection(force: Bool) {
Task { Task {
do { do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force) let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
@@ -515,54 +454,11 @@ struct GroupMemberInfoView: View {
} }
} }
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))")
}
}
}
struct GroupMemberInfoView_Previews: PreviewProvider { struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupMemberInfoView( GroupMemberInfoView(
groupInfo: GroupInfo.sampleData, groupInfo: GroupInfo.sampleData,
groupMember: GMember.sampleData member: GroupMember.sampleData
) )
} }
} }
@@ -27,7 +27,8 @@ struct GroupPreferencesView: View {
featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.reactions, $preferences.reactions.enable) featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable) featureSection(.voice, $preferences.voice.enable)
featureSection(.files, $preferences.files.enable) // TODO uncomment in 5.3
// featureSection(.files, $preferences.files.enable)
if groupInfo.canEdit { if groupInfo.canEdit {
Section { Section {
@@ -9,18 +9,6 @@
import SwiftUI import SwiftUI
import SimpleXChat 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 { struct GroupProfileView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@@ -30,7 +18,8 @@ struct GroupProfileView: View {
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @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 @FocusState private var focusDisplayName
var body: some View { var body: some View {
@@ -58,29 +47,20 @@ struct GroupProfileView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack(alignment: .topLeading) { ZStack(alignment: .leading) {
if !validNewProfileName() { if !validDisplayName(groupProfile.displayName) {
Button { Image(systemName: "exclamationmark.circle")
alert = .invalidName(validName: mkValidName(groupProfile.displayName)) .foregroundColor(.red)
} label: { .padding(.bottom, 10)
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
profileNameTextEdit("Group display name", $groupProfile.displayName) profileNameTextEdit("Group display name", $groupProfile.displayName)
.focused($focusDisplayName) .focused($focusDisplayName)
} }
.padding(.bottom) profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
.padding(.bottom)
}
HStack(spacing: 20) { HStack(spacing: 20) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
Button("Save group profile") { saveProfile() } Button("Save group profile") { saveProfile() }
.disabled(!canUpdateProfile()) .disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName))
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -119,39 +99,27 @@ struct GroupProfileView: View {
focusDisplayName = true focusDisplayName = true
} }
} }
.alert(item: $alert) { a in .alert(isPresented: $showSaveErrorAlert) {
switch a { Alert(
case let .saveError(err): title: Text("Error saving group profile"),
return Alert( message: Text("\(saveGroupError ?? "Unexpected error")")
title: Text("Error saving group profile"), )
message: Text(err)
)
case let .invalidName(name):
return createInvalidNameAlert(name, $groupProfile.displayName)
}
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { hideKeyboard() } .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 { func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name) TextField(label, text: name)
.padding(.leading, 32) .textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
.padding(.leading, 28)
} }
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run { await MainActor.run {
groupInfo = gInfo groupInfo = gInfo
@@ -160,8 +128,9 @@ struct GroupProfileView: View {
} }
} catch let error { } catch let error {
let err = responseError(error) let err = responseError(error)
alert = .saveError(err: err) saveGroupError = err
logger.error("GroupProfile apiUpdateGroup error: \(err)") showSaveErrorAlert = true
logger.error("UserProfile apiUpdateProfile error: \(err)")
} }
} }
} }
@@ -19,7 +19,7 @@ struct ScanCodeView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode) CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .border(.gray)
Text("Scan security code from your contact's app.") Text("Scan security code from your contact's app.")
.padding(.top) .padding(.top)
} }
@@ -64,7 +64,7 @@ struct VerifyCodeView: View {
HStack { HStack {
NavigationLink { NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify) ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.inline)
.navigationTitle("Scan code") .navigationTitle("Scan code")
} label: { } label: {
Label("Scan code", systemImage: "qrcode") Label("Scan code", systemImage: "qrcode")
@@ -32,102 +32,56 @@ struct ChatListNavLink: View {
@State private var showJoinGroupDialog = false @State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false @State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false @State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View { var body: some View {
Group { switch chat.chatInfo {
switch chat.chatInfo { case let .direct(contact):
case let .direct(contact): contactNavLink(contact)
contactNavLink(contact) case let .group(groupInfo):
case let .group(groupInfo): groupNavLink(groupInfo)
groupNavLink(groupInfo) case let .contactRequest(cReq):
case let .contactRequest(cReq): contactRequestNavLink(cReq)
contactRequestNavLink(cReq) case let .contactConnection(cConn):
case let .contactConnection(cConn): contactConnectionNavLink(cConn)
contactConnectionNavLink(cConn) case let .invalidJSON(json):
case let .invalidJSON(json): invalidJSONPreview(json)
invalidJSONPreview(json)
}
}
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
} }
} }
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group { let v = NavLinkPlain(
if contact.activeConn == nil && contact.profile.contactLink != nil { tag: chat.chatInfo.id,
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) selection: $chatModel.chatId,
.frame(height: rowHeights[dynamicTypeSize]) label: { ChatPreviewView(chat: chat) },
.swipeActions(edge: .trailing, allowsFullSwipe: true) { disabled: !contact.ready
Button { )
showDeleteContactActionSheet = true .swipeActions(edge: .leading, allowsFullSwipe: true) {
} label: { markReadButton()
Label("Delete", systemImage: "trash") toggleFavoriteButton()
} toggleNtfsButton(chat)
.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])
}
} }
.actionSheet(isPresented: $showDeleteContactActionSheet) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
if contact.ready && contact.active { if !chat.chatItems.isEmpty {
return ActionSheet( clearChatButton()
title: Text("Delete contact?\nThis cannot be undone!"), }
buttons: [ Button {
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, AlertManager.shared.showAlert(
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } }, contact.ready
.cancel() ? deleteContactAlert(chat.chatInfo)
] : deletePendingContactAlert(chat, contact)
)
} else {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
) )
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
.frame(height: rowHeights[dynamicTypeSize])
if contact.ready {
v
} else {
v.onTapGesture {
AlertManager.shared.showAlert(pendingContactAlert(chat, contact))
} }
} }
} }
@@ -135,7 +89,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) ChatPreviewView(chat: chat)
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
@@ -146,16 +100,12 @@ struct ChatListNavLink: View {
.onTapGesture { showJoinGroupDialog = true } .onTapGesture { showJoinGroupDialog = true }
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
inProgress = true joinGroup(groupInfo.groupId)
joinGroup(groupInfo.groupId) {
await MainActor.run { inProgress = false }
}
} }
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
} }
.disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat)
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
@@ -172,7 +122,7 @@ struct ChatListNavLink: View {
NavLinkPlain( NavLinkPlain(
tag: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
@@ -197,10 +147,7 @@ struct ChatListNavLink: View {
private func joinGroupButton() -> some View { private func joinGroupButton() -> some View {
Button { Button {
inProgress = true joinGroup(chat.chatInfo.apiId)
joinGroup(chat.chatInfo.apiId) {
await MainActor.run { inProgress = false }
}
} label: { } label: {
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
} }
@@ -275,15 +222,9 @@ struct ChatListNavLink: View {
ContactRequestView(contactRequest: contactRequest, chat: chat) ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } Task { await acceptContactRequest(contactRequest) }
} label: { Label("Accept", systemImage: "checkmark") } } label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
.tint(.accentColor) .tint(chatModel.incognito ? .indigo : .accentColor)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
Label("Accept incognito", systemImage: "theatermasks")
}
.tint(.indigo)
Button { Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: { } label: {
@@ -293,10 +234,9 @@ struct ChatListNavLink: View {
} }
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } } Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
} }
} }
@@ -331,6 +271,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 { private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert( Alert(
title: Text("Delete group?"), title: Text("Delete group?"),
@@ -432,17 +383,6 @@ struct ChatListNavLink: View {
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .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 { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
@@ -471,22 +411,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
) )
} }
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool { func joinGroup(_ groupId: Int64) {
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) {
Task { Task {
logger.debug("joinGroup") logger.debug("joinGroup")
do { do {
@@ -501,9 +426,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
await deleteGroup() await deleteGroup()
} }
await onComplete()
} catch let error { } catch let error {
await onComplete()
let a = getErrorAlert(error, "Error joining group") let a = getErrorAlert(error, "Error joining group")
AlertManager.shared.showAlertMsg(title: a.title, message: a.message) AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
} }
@@ -15,7 +15,6 @@ struct ChatListView: View {
@State private var searchText = "" @State private var searchText = ""
@State private var showAddChat = false @State private var showAddChat = false
@State private var userPickerVisible = false @State private var userPickerVisible = false
@State private var showConnectDesktop = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View { var body: some View {
@@ -30,7 +29,7 @@ struct ChatListView: View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
NavStackCompat( NavStackCompat(
isActive: Binding( isActive: Binding(
get: { chatModel.chatId != nil }, get: { ChatModel.shared.chatId != nil },
set: { _ in } set: { _ in }
), ),
destination: chatView destination: chatView
@@ -49,14 +48,7 @@ struct ChatListView: View {
} }
} }
} }
UserPicker( UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible)
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
} }
} }
@@ -68,6 +60,8 @@ struct ChatListView: View {
chatList chatList
} }
} }
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
.onDisappear() { withAnimation { userPickerVisible = false } } .onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
@@ -114,6 +108,11 @@ struct ChatListView: View {
} }
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 4) { HStack(spacing: 4) {
if (chatModel.incognito) {
Image(systemName: "theatermasks")
.foregroundColor(.indigo)
.padding(.trailing, 8)
}
Text("Chats") Text("Chats")
.font(.headline) .font(.headline)
if chatModel.chats.count > 0 { if chatModel.chats.count > 0 {
@@ -185,6 +184,13 @@ struct ChatListView: View {
showAddChat = true showAddChat = true
} }
connectButton("or chat with the developers") {
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
}
}
.padding(.top, 10)
Spacer() Spacer()
Text("You have no chats") Text("You have no chats")
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -12,12 +12,9 @@ import SimpleXChat
struct ChatPreviewView: View { struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0) var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var body: some View { var body: some View {
let cItem = chat.chatItems.last let cItem = chat.chatItems.last
return HStack(spacing: 8) { return HStack(spacing: 8) {
@@ -44,9 +41,11 @@ struct ChatPreviewView: View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem) chatMessagePreview(cItem)
chatStatusImage() if case .direct = chat.chatInfo {
.padding(.top, 26) chatStatusImage()
.frame(maxWidth: .infinity, alignment: .trailing) .padding(.top, 24)
.frame(maxWidth: .infinity, alignment: .trailing)
}
} }
.padding(.trailing, 8) .padding(.trailing, 8)
@@ -58,28 +57,24 @@ struct ChatPreviewView: View {
} }
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
switch chat.chatInfo { if case let .group(groupInfo) = chat.chatInfo {
case let .direct(contact):
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memLeft: inactiveIcon() case .memLeft:
case .memRemoved: inactiveIcon() groupInactiveIcon()
case .memGroupDeleted: inactiveIcon() case .memRemoved:
groupInactiveIcon()
case .memGroupDeleted:
groupInactiveIcon()
default: EmptyView() default: EmptyView()
} }
default: } else {
EmptyView() EmptyView()
} }
} }
@ViewBuilder private func inactiveIcon() -> some View { @ViewBuilder private func groupInactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill") Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65)) .foregroundColor(.secondary)
.background(Circle().foregroundColor(Color(uiColor: .systemBackground))) .background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
} }
@@ -88,6 +83,7 @@ struct ChatPreviewView: View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t) previewTitle(contact.verified == true ? verifiedIcon + t : t)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case let .group(groupInfo): case let .group(groupInfo):
let v = previewTitle(t) let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
@@ -110,19 +106,14 @@ struct ChatPreviewView: View {
.kerning(-2) .kerning(-2)
} }
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View { private func chatPreviewLayout(_ text: Text) -> some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
let t = text text
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8) .padding(.leading, 8)
.padding(.trailing, 36) .padding(.trailing, 36)
if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy)
} else {
t
}
let s = chat.chatStats let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat { if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount) unreadCountText(s.unreadCount)
@@ -184,21 +175,14 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View { @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft { if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true) chatPreviewLayout(messageDraft(draft))
} else if let cItem = cItem { } else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem)) chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else { } else {
switch (chat.chatInfo) { switch (chat.chatInfo) {
case let .direct(contact): case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil { if !contact.ready {
chatPreviewInfoText("Tap to Connect") chatPreviewInfoText("connecting…")
.foregroundColor(.accentColor)
} else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message")
} else if contact.active {
chatPreviewInfoText("connecting…")
}
} }
case let .group(groupInfo): case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
@@ -214,7 +198,10 @@ struct ChatPreviewView: View {
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
: chatPreviewInfoText("you are invited to group") : (chatModel.incognito
? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
: chatPreviewInfoText("you are invited to group")
)
} }
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
@@ -241,45 +228,23 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatStatusImage() -> some View { @ViewBuilder private func chatStatusImage() -> some View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
if contact.active && contact.activeConn != nil { switch (chatModel.contactNetworkStatus(contact)) {
switch (chatModel.contactNetworkStatus(contact)) { case .connected: EmptyView()
case .connected: incognitoIcon(chat.chatInfo.incognito) case .error:
case .error: Image(systemName: "exclamationmark.circle")
Image(systemName: "exclamationmark.circle") .resizable()
.resizable() .scaledToFit()
.scaledToFit() .frame(width: 17, height: 17)
.frame(width: 17, height: 17) .foregroundColor(.secondary)
.foregroundColor(.secondary) default:
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito)
}
case .group:
if progressByTimeout {
ProgressView() ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito)
} }
default: 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 { func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "") Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
} }
@@ -290,30 +255,30 @@ struct ChatPreviewView_Previews: PreviewProvider {
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [] chatItems: []
), progressByTimeout: Binding.constant(false)) ))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
), progressByTimeout: Binding.constant(false)) ))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
), progressByTimeout: Binding.constant(false)) ))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
), progressByTimeout: Binding.constant(false)) ))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
), progressByTimeout: Binding.constant(false)) ))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.group, 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.")], 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) chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
), progressByTimeout: Binding.constant(false)) ))
} }
.previewLayout(.fixed(width: 360, height: 78)) .previewLayout(.fixed(width: 360, height: 78))
} }
@@ -15,7 +15,6 @@ struct ContactConnectionInfo: View {
@State var contactConnection: PendingContactConnection @State var contactConnection: PendingContactConnection
@State private var alert: CCInfoAlert? @State private var alert: CCInfoAlert?
@State private var localAlias = "" @State private var localAlias = ""
@State private var showIncognitoSheet = false
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
enum CCInfoAlert: Identifiable { enum CCInfoAlert: Identifiable {
@@ -32,14 +31,19 @@ struct ContactConnectionInfo: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
let v = List { List {
Group { Group {
Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection") Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.padding(.bottom) .padding(.bottom, 16)
Text(contactConnectionText(contactConnection)) Text(contactConnectionText(contactConnection))
.padding(.bottom, 16)
if let connReqInv = contactConnection.connReqInv {
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
}
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
@@ -61,16 +65,10 @@ struct ContactConnectionInfo: View {
if contactConnection.initiated, if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv { let connReqInv = contactConnection.connReqInv {
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
incognitoEnabled()
shareLinkButton(connReqInv)
oneTimeLinkLearnMoreButton()
} else { } else {
incognitoEnabled()
oneTimeLinkLearnMoreButton() oneTimeLinkLearnMoreButton()
} }
} footer: {
sharedProfileInfo(contactConnection.incognito)
} }
Section { 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 .alert(item: $alert) { _alert in
switch _alert { switch _alert {
@@ -119,7 +109,7 @@ struct ContactConnectionInfo: View {
if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) { if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) {
await MainActor.run { await MainActor.run {
contactConnection = conn contactConnection = conn
m.updateContactConnection(conn) ChatModel.shared.updateContactConnection(conn)
dismiss() dismiss()
} }
} }
@@ -138,30 +128,6 @@ struct ContactConnectionInfo: View {
) )
: "You will be connected when your contact's device is online, please wait or check later!" : "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()
}
}
}
} }
struct ContactConnectionInfo_Previews: PreviewProvider { struct ContactConnectionInfo_Previews: PreviewProvider {
@@ -58,14 +58,10 @@ struct ContactConnectionView: View {
} }
.padding(.bottom, 2) .padding(.bottom, 2)
ZStack(alignment: .topTrailing) { Text(contactConnection.description)
Text(contactConnection.description) .frame(alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8)
incognitoIcon(contactConnection.incognito) .padding(.bottom, 2)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 8)
Spacer() Spacer()
} }
@@ -24,7 +24,7 @@ struct ContactRequestView: View {
Text(contactRequest.chatViewName) Text(contactRequest.chatViewName)
.font(.title3) .font(.title3)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.accentColor) .foregroundColor(chatModel.incognito ? .indigo : .accentColor)
.padding(.leading, 8) .padding(.leading, 8)
.frame(alignment: .topLeading) .frame(alignment: .topLeading)
Spacer() Spacer()
@@ -13,7 +13,6 @@ struct UserPicker: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Binding var showSettings: Bool @Binding var showSettings: Bool
@Binding var showConnectDesktop: Bool
@Binding var userPickerVisible: Bool @Binding var userPickerVisible: Bool
@State var scrollViewContentSize: CGSize = .zero @State var scrollViewContentSize: CGSize = .zero
@State var disableScrolling: Bool = true @State var disableScrolling: Bool = true
@@ -63,13 +62,6 @@ struct UserPicker: View {
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
.frame(maxHeight: scrollViewContentSize.height) .frame(maxHeight: scrollViewContentSize.height)
menuButton("Use from desktop", icon: "desktopcomputer") {
showConnectDesktop = true
withAnimation {
userPickerVisible.toggle()
}
}
Divider()
menuButton("Settings", icon: "gearshape") { menuButton("Settings", icon: "gearshape") {
showSettings = true showSettings = true
withAnimation { withAnimation {
@@ -93,7 +85,7 @@ struct UserPicker: View {
do { do {
m.users = try listUsers() m.users = try listUsers()
} catch let error { } 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()) .overlay(DetermineWidth())
Spacer() Spacer()
Image(systemName: icon) Image(systemName: icon)
.symbolRenderingMode(.monochrome) // .frame(width: 24, alignment: .center)
.foregroundColor(.secondary)
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 22) .padding(.vertical, 22)
@@ -179,7 +170,6 @@ struct UserPicker_Previews: PreviewProvider {
m.users = [UserInfo.sampleData, UserInfo.sampleData] m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker( return UserPicker(
showSettings: Binding.constant(false), showSettings: Binding.constant(false),
showConnectDesktop: Binding.constant(false),
userPickerVisible: Binding.constant(true) userPickerVisible: Binding.constant(true)
) )
.environmentObject(m) .environmentObject(m)
@@ -133,7 +133,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
config.filter = .any(of: [.images, .videos]) config.filter = .any(of: [.images, .videos])
config.selectionLimit = selectionLimit config.selectionLimit = selectionLimit
config.selection = .ordered config.selection = .ordered
config.preferredAssetRepresentationMode = .current //config.preferredAssetRepresentationMode = .current
let controller = PHPickerViewController(configuration: config) let controller = PHPickerViewController(configuration: config)
controller.delegate = context.coordinator controller.delegate = context.coordinator
return controller return controller
@@ -8,15 +8,11 @@
import SwiftUI import SwiftUI
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { func showShareSheet(items: [Any]) {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) 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) presentedViewController.present(activityViewController, animated: true)
} }
} }
@@ -6,7 +6,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import AVKit import AVKit
import Combine
struct VideoPlayerView: UIViewRepresentable { struct VideoPlayerView: UIViewRepresentable {
@@ -38,14 +37,6 @@ struct VideoPlayerView: UIViewRepresentable {
player.seek(to: CMTime.zero) player.seek(to: CMTime.zero)
player.play() 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 return controller.view
} }
@@ -59,13 +50,12 @@ struct VideoPlayerView: UIViewRepresentable {
class Coordinator: NSObject { class Coordinator: NSObject {
var controller: AVPlayerViewController? var controller: AVPlayerViewController?
var timeObserver: Any? = nil var timeObserver: Any? = nil
var publisher: AnyCancellable? = nil
deinit { deinit {
print("deinit coordinator of VideoPlayer")
if let timeObserver = timeObserver { if let timeObserver = timeObserver {
NotificationCenter.default.removeObserver(timeObserver) NotificationCenter.default.removeObserver(timeObserver)
} }
publisher?.cancel()
} }
} }
} }
@@ -12,94 +12,40 @@ import SimpleXChat
struct AddContactView: View { struct AddContactView: View {
@EnvironmentObject private var chatModel: ChatModel @EnvironmentObject private var chatModel: ChatModel
@Binding var contactConnection: PendingContactConnection? var contactConnection: PendingContactConnection? = nil
var connReqInvitation: String var connReqInvitation: String
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View { var body: some View {
VStack { List {
List { OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
Section { .listRowBackground(Color.clear)
if connReqInvitation != "" { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
SimpleXLinkQRCode(uri: connReqInvitation)
} else { Section("1-time link") {
ProgressView() oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
.progressViewStyle(.circular)
.scaleEffect(2)
.frame(maxWidth: .infinity)
.padding(.vertical)
}
IncognitoToggle(incognitoEnabled: $incognitoDefault)
.disabled(contactConnection == nil)
shareLinkButton(connReqInvitation)
oneTimeLinkLearnMoreButton()
} header: {
Text("1-time link")
} footer: {
sharedProfileInfo(incognitoDefault)
}
} }
} }
.onAppear { chatModel.connReqInv = connReqInvitation } .onAppear { chatModel.connReqInv = connReqInvitation }
.onChange(of: incognitoDefault) { incognito in
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
}
}
} }
} }
struct IncognitoToggle: View { @ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
@Binding var incognitoEnabled: Bool if connReqInvitation != "" {
@State private var showIncognitoSheet = false QRCode(uri: connReqInvitation)
} else {
var body: some View { ProgressView()
ZStack(alignment: .leading) { .progressViewStyle(.circular)
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks") .scaleEffect(2)
.frame(maxWidth: 24, maxHeight: 24, alignment: .center) .frame(maxWidth: .infinity)
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary) .padding(.vertical)
.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()
}
} }
shareLinkButton(connReqInvitation)
oneTimeLinkLearnMoreButton()
} }
func sharedProfileInfo(_ incognito: Bool) -> Text { private func shareLinkButton(_ connReqInvitation: String) -> some View {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
}
func shareLinkButton(_ connReqInvitation: String) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)]) showShareSheet(items: [connReqInvitation])
} label: { } label: {
settingsRow("square.and.arrow.up") { settingsRow("square.and.arrow.up") {
Text("Share 1-time link") Text("Share 1-time link")
@@ -119,11 +65,26 @@ func oneTimeLinkLearnMoreButton() -> some View {
} }
} }
struct AddContactView_Previews: PreviewProvider { struct OneTimeLinkProfileText: View {
static var previews: some View { @EnvironmentObject private var chatModel: ChatModel
AddContactView( var contactConnection: PendingContactConnection? = nil
contactConnection: Binding.constant(PendingContactConnection.getSampleData()), var connReqInvitation: String
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"
) 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")
} }
} }
+86 -109
View File
@@ -12,45 +12,27 @@ import SimpleXChat
struct AddGroupView: View { struct AddGroupView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var chat: Chat? @State private var chat: Chat?
@State private var groupInfo: GroupInfo? @State private var groupInfo: GroupInfo?
@State private var profile = GroupProfile(displayName: "", fullName: "") @State private var profile = GroupProfile(displayName: "", fullName: "")
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
@FocusState private var focusFullName
@State private var showChooseSource = false @State private var showChooseSource = false
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @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 { var body: some View {
if let chat = chat, let groupInfo = groupInfo { if let chat = chat, let groupInfo = groupInfo {
if !groupInfo.membership.memberIncognito { AddGroupMembersViewCommon(
AddGroupMembersViewCommon( chat: chat,
chat: chat, groupInfo: groupInfo,
groupInfo: groupInfo, creatingGroup: true,
creatingGroup: true, showFooterCounter: false
showFooterCounter: false ) { _ in
) { _ in dismiss()
dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { m.chatId = groupInfo.id
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
}
} }
} }
} else { } else {
@@ -59,62 +41,87 @@ struct AddGroupView: View {
} }
func createGroupView() -> some View { func createGroupView() -> some View {
List { VStack(alignment: .leading) {
Group { Text("Create secret group")
Text("Create secret group") .font(.largeTitle)
.font(.largeTitle) .padding(.vertical, 4)
.bold() Text("The group is fully decentralized it is visible only to the members.")
.fixedSize(horizontal: false, vertical: true) .padding(.bottom, 4)
.padding(.bottom, 24) if (m.incognito) {
.onTapGesture(perform: hideKeyboard) 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: .center) {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) profileImageView(profile.image)
.aspectRatio(1, contentMode: .fit) if profile.image != nil {
.frame(maxWidth: 128, maxHeight: 128) Button {
if profile.image != nil { profile.image = nil
Button { } label: {
profile.image = nil Image(systemName: "multiply")
} label: { .resizable()
Image(systemName: "multiply") .aspectRatio(contentMode: .fit)
.resizable() .frame(width: 12)
.aspectRatio(contentMode: .fit)
.frame(width: 12)
}
} }
} }
}
editImageButton { showChooseSource = true } editImageButton { showChooseSource = true }
.buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
}
.frame(maxWidth: .infinity, alignment: .center)
} }
.listRowBackground(Color.clear) .frame(maxWidth: .infinity, alignment: .center)
.listRowSeparator(.hidden) .padding(.bottom, 4)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section { ZStack(alignment: .topLeading) {
groupNameTextField() if !validDisplayName(profile.displayName) {
Button(action: createGroup) { Image(systemName: "exclamationmark.circle")
settingsRow("checkmark", color: .accentColor) { Text("Create group") } .foregroundColor(.red)
.padding(.top, 4)
} }
.disabled(!canCreateProfile()) textField("Group display name", text: $profile.displayName)
IncognitoToggle(incognitoEnabled: $incognitoDefault) .focused($focusDisplayName)
} footer: { .submitLabel(.next)
VStack(alignment: .leading, spacing: 4) { .onSubmit {
sharedGroupProfileInfo(incognitoDefault) if canCreateProfile() { focusFullName = true }
Text("Fully decentralized visible only to members.") else { focusDisplayName = true }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture(perform: hideKeyboard)
} }
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() { .onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true focusDisplayName = true
} }
} }
.padding()
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") { Button("Take picture") {
showTakePhoto = true showTakePhoto = true
@@ -134,9 +141,6 @@ struct AddGroupView: View {
didSelectItem in showImagePicker = false didSelectItem in showImagePicker = false
} }
} }
.alert(isPresented: $showInvalidNameAlert) {
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
}
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
if let image = image { if let image = image {
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
@@ -144,52 +148,26 @@ struct AddGroupView: View {
profile.image = nil profile.image = nil
} }
} }
} .contentShape(Rectangle())
.onTapGesture { hideKeyboard() }
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() }
}
}
} }
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View { func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
TextField(placeholder, text: text) TextField(placeholder, text: text)
.padding(.leading, 36) .textInputAutocapitalization(.never)
} .disableAutocorrection(true)
.padding(.leading, 28)
func sharedGroupProfileInfo(_ incognito: Bool) -> Text { .padding(.bottom)
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
} }
func createGroup() { func createGroup() {
hideKeyboard() hideKeyboard()
do { do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) let gInfo = try apiNewGroup(profile)
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task { Task {
let groupMembers = await apiListMembers(gInfo.groupId) let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run { await MainActor.run {
m.groupMembers = groupMembers.map { GMember.init($0) } ChatModel.shared.groupMembers = groupMembers
} }
} }
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
@@ -210,8 +188,7 @@ struct AddGroupView: View {
} }
func canCreateProfile() -> Bool { func canCreateProfile() -> Bool {
let name = profile.displayName.trimmingCharacters(in: .whitespaces) profile.displayName != "" && validDisplayName(profile.displayName)
return name != "" && validDisplayName(name)
} }
} }
@@ -7,7 +7,6 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
enum CreateLinkTab { enum CreateLinkTab {
case oneTime case oneTime
@@ -25,7 +24,6 @@ struct CreateLinkView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@State var selection: CreateLinkTab @State var selection: CreateLinkTab
@State var connReqInvitation: String = "" @State var connReqInvitation: String = ""
@State var contactConnection: PendingContactConnection? = nil
@State private var creatingConnReq = false @State private var creatingConnReq = false
var viaNavLink = false var viaNavLink = false
@@ -41,7 +39,7 @@ struct CreateLinkView: View {
private func createLinkView() -> some View { private func createLinkView() -> some View {
TabView(selection: $selection) { TabView(selection: $selection) {
AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation) AddContactView(connReqInvitation: connReqInvitation)
.tabItem { .tabItem {
Label( Label(
connReqInvitation == "" connReqInvitation == ""
@@ -58,7 +56,7 @@ struct CreateLinkView: View {
.tag(CreateLinkTab.longTerm) .tag(CreateLinkTab.longTerm)
} }
.onChange(of: selection) { _ in .onChange(of: selection) { _ in
if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq { if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq {
createInvitation() createInvitation()
} }
} }
@@ -71,14 +69,12 @@ struct CreateLinkView: View {
private func createInvitation() { private func createInvitation() {
creatingConnReq = true creatingConnReq = true
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { let connReq = await apiAddContact()
await MainActor.run { await MainActor.run {
if let connReq = connReq {
connReqInvitation = connReq connReqInvitation = connReq
contactConnection = pcc
m.connReqInv = connReq m.connReqInv = connReq
} } else {
} else {
await MainActor.run {
creatingConnReq = false creatingConnReq = false
} }
} }
+52 -385
View File
@@ -10,13 +10,13 @@ import SwiftUI
import SimpleXChat import SimpleXChat
enum NewChatAction: Identifiable { enum NewChatAction: Identifiable {
case createLink(link: String, connection: PendingContactConnection) case createLink(link: String)
case connectViaLink case connectViaLink
case createGroup case createGroup
var id: String { var id: String {
switch self { switch self {
case let .createLink(link, _): return "createLink \(link)" case let .createLink(link): return "createLink \(link)"
case .connectViaLink: return "connectViaLink" case .connectViaLink: return "connectViaLink"
case .createGroup: return "createGroup" case .createGroup: return "createGroup"
} }
@@ -41,8 +41,8 @@ struct NewChatButton: View {
} }
.sheet(item: $actionSheet) { sheet in .sheet(item: $actionSheet) { sheet in
switch sheet { switch sheet {
case let .createLink(link, pcc): case let .createLink(link):
CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc) CreateLinkView(selection: .oneTime, connReqInvitation: link)
case .connectViaLink: ConnectViaLinkView() case .connectViaLink: ConnectViaLinkView()
case .createGroup: AddGroupView() case .createGroup: AddGroupView()
} }
@@ -51,405 +51,72 @@ struct NewChatButton: View {
func addContactAction() { func addContactAction() {
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { if let connReq = await apiAddContact() {
actionSheet = .createLink(link: connReq, connection: pcc) actionSheet = .createLink(link: connReq)
} }
} }
} }
} }
enum PlanAndConnectAlert: Identifiable {
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case invitationLinkConnecting(connectionLink: String)
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
var id: String {
switch self {
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
}
}
}
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
switch alert {
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own one-time link!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case .invitationLinkConnecting:
return Alert(
title: Text("Already connecting!"),
message: Text("You are already connecting via this one-time link!")
)
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own SimpleX address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat connection request?"),
message: Text("You have already requested connection via this address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Join group?"),
message: Text("You will connect to all group members."),
primaryButton: .default(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat join request?"),
message: Text("You are already joining the group via this link!"),
primaryButton: .destructive(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnecting(_, groupInfo):
if let groupInfo = groupInfo {
return Alert(
title: Text("Group already exists!"),
message: Text("You are already joining the group \(groupInfo.displayName).")
)
} else {
return Alert(
title: Text("Already joining the group!"),
message: Text("You are already joining the group via this link.")
)
}
}
}
enum PlanAndConnectActionSheet: Identifiable {
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
var id: String {
switch self {
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
}
}
}
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
switch sheet {
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
return ActionSheet(
title: Text("Connect with \(contact.chatViewName)"),
buttons: [
.default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false) },
.default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
if let incognito = incognito {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
}
}
}
func planAndConnect(
_ connectionLink: String,
showAlert: @escaping (PlanAndConnectAlert) -> Void,
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
dismiss: Bool,
incognito: Bool?
) {
Task {
do {
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
case .ok:
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
}
case .ownLink:
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
}
case let .connecting(contact_):
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
if let contact = contact_ {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
} else {
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
}
case let .known(contact):
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
case let .contactAddress(cap):
switch cap {
case .ok:
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
}
case .ownLink:
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
}
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
}
case let .connectingProhibit(contact):
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
case let .contactViaAddress(contact):
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
}
}
case let .groupLink(glp):
switch glp {
case .ok:
if let incognito = incognito {
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
}
case let .ownLink(groupInfo):
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
}
case let .connectingProhibit(groupInfo_):
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
}
}
} catch {
logger.debug("planAndConnect, plan error")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
}
}
}
}
private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool) {
Task {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
_ = await connectContactViaAddress(contact.contactId, incognito)
}
}
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
Task {
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
let crt: ConnReqType
if let plan = connectionPlan {
crt = planToConnReqType(plan)
} else {
crt = connReqType
}
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
} else {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
}
} else {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
}
}
}
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = c.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = c.id
showAlreadyExistsAlert?()
}
}
}
}
}
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared
if let g = m.getGroupChat(groupInfo.groupId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = g.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = g.id
showAlreadyExistsAlert?()
}
}
}
}
}
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connecting to \(contact.displayName)."
)
}
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
mkAlert(
title: "Group already exists",
message: "You are already in group \(groupInfo.displayName)."
)
}
enum ConnReqType: Equatable { enum ConnReqType: Equatable {
case invitation
case contact case contact
case groupLink case invitation
}
var connReqSentText: LocalizedStringKey { func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
switch self { Task {
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!" if let connReqType = await apiConnect(connReq: connectionLink) {
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!" DispatchQueue.main.async {
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!" dismiss?()
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
}
} else {
DispatchQueue.main.async {
dismiss?()
}
} }
} }
} }
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { struct CReqClientData: Decodable {
switch connectionPlan { var type: String
case .invitationLink: return .invitation var groupLinkId: String?
case .contactAddress: return .contact }
case .groupLink: return .groupLink
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 { func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert( return mkAlert(
title: "Connection request sent!", title: "Connection request sent!",
message: type.connReqSentText 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!"
) )
} }
@@ -7,95 +7,88 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
struct PasteToConnectView: View { struct PasteToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State private var connectionLink: String = "" @State private var connectionLink: String = ""
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@FocusState private var linkEditorFocused: Bool
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View { var body: some View {
List { ScrollView {
Text("Connect via link") VStack(alignment: .leading) {
.font(.largeTitle) Text("Connect via link")
.bold() .font(.largeTitle)
.fixedSize(horizontal: false, vertical: true) .bold()
.listRowBackground(Color.clear) .fixedSize(horizontal: false, vertical: true)
.listRowSeparator(.hidden) .padding(.vertical)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) Text("Paste the link you received into the box below to connect with your contact.")
.onTapGesture { linkEditorFocused = false } .padding(.bottom, 4)
if (chatModel.incognito) {
Section { HStack {
linkEditor() Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Button { Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
if connectionLink == "" {
connectionLink = UIPasteboard.general.string ?? ""
} else {
connectionLink = ""
} }
} label: { .padding(.bottom)
if connectionLink == "" { } else {
settingsRow("doc.plaintext") { Text("Paste") } HStack {
} else { Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
settingsRow("multiply") { Text("Clear") } Spacer().frame(width: 8)
Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
} }
} .padding(.bottom)
Button {
connect()
} label: {
settingsRow("link") { Text("Connect") }
}
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
}
private func linkEditor() -> some View {
ZStack {
Group {
if connectionLink.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact.", comment: "placeholder")))
.foregroundColor(.secondary)
.disabled(true)
} }
TextEditor(text: $connectionLink) TextEditor(text: $connectionLink)
.onSubmit(connect) .onSubmit(connect)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.disableAutocorrection(true) .disableAutocorrection(true)
.focused($linkEditorFocused) .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.")
} }
.allowsTightening(false) .padding()
.padding(.horizontal, -5) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, -8)
.frame(height: 180, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
private func connect() { private func connect() {
let link = connectionLink.trimmingCharacters(in: .whitespaces) let link = connectionLink.trimmingCharacters(in: .whitespaces)
planAndConnect( if let crData = parseLinkQueryData(link),
link, checkCRDataGroup(crData) {
showAlert: { alert = $0 }, dismiss()
showActionSheet: { sheet = $0 }, AlertManager.shared.showAlert(groupLinkAlert(link))
dismiss: true, } else {
incognito: incognitoDefault connectViaLink(link, dismiss)
) }
} }
} }
@@ -28,22 +28,6 @@ struct MutableQRCode: View {
} }
} }
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 body: some View {
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor)
}
}
func simplexChatLink(_ uri: String) -> String {
uri.starts(with: "simplex:/")
? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
: uri
}
struct QRCode: View { struct QRCode: View {
let uri: String let uri: String
var withLogo: Bool = true var withLogo: Bool = true
@@ -7,14 +7,11 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
import CodeScanner import CodeScanner
struct ScanToConnectView: View { struct ScanToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -22,49 +19,46 @@ struct ScanToConnectView: View {
Text("Scan QR code") Text("Scan QR code")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical) .padding(.vertical)
if (chatModel.incognito) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode) HStack {
.aspectRatio(1, contentMode: .fit) Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
.cornerRadius(12) Spacer().frame(width: 8)
Text("A random profile will be sent to your contact").font(.footnote)
IncognitoToggle(incognitoEnabled: $incognitoDefault) }
.padding(.horizontal) .padding(.bottom)
.padding(.vertical, 6) } else {
.background( HStack {
RoundedRectangle(cornerRadius: 12, style: .continuous) Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
.fill(Color(uiColor: .systemBackground)) Spacer().frame(width: 8)
) Text("Your chat profile will be sent to your contact").font(.footnote)
.padding(.top) }
.padding(.bottom)
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
} }
.frame(maxWidth: .infinity, alignment: .leading) ZStack {
.font(.footnote) CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.foregroundColor(.secondary) .aspectRatio(1, contentMode: .fit)
.padding(.horizontal) .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() .padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
.background(Color(.systemGroupedBackground))
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
} }
func processQRCode(_ resp: Result<ScanResult, ScanError>) { func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp { switch resp {
case let .success(r): case let .success(r):
planAndConnect( if let crData = parseLinkQueryData(r.string),
r.string, checkCRDataGroup(crData) {
showAlert: { alert = $0 }, dismiss()
showActionSheet: { sheet = $0 }, AlertManager.shared.showAlert(groupLinkAlert(r.string))
dismiss: true, } else {
incognito: incognitoDefault Task { connectViaLink(r.string, dismiss) }
) }
case let .failure(e): case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
dismiss() dismiss()
@@ -9,244 +9,175 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
enum UserProfileAlert: Identifiable {
case duplicateUserError
case createUserError(error: LocalizedStringKey)
case invalidNameError(validName: String)
var id: String {
switch self {
case .duplicateUserError: return "duplicateUserError"
case .createUserError: return "createUserError"
case let .invalidNameError(validName): return "invalidNameError \(validName)"
}
}
}
struct CreateProfile: View { 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 @EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var displayName: String = "" @State private var displayName: String = ""
@State private var fullName: String = ""
@FocusState private var focusDisplayName @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 { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Group { Text("Create your profile")
Text("Create your profile") .font(.largeTitle)
.font(.largeTitle) .bold()
.bold() .padding(.bottom, 4)
Text("Your profile, contacts and delivered messages are stored on your device.") .frame(maxWidth: .infinity)
.foregroundColor(.secondary) Text("Your profile, contacts and delivered messages are stored on your device.")
Text("The profile is only shared with your contacts.") .padding(.bottom, 4)
.foregroundColor(.secondary) Text("The profile is only shared with your contacts.")
.padding(.bottom) .padding(.bottom)
}
.padding(.bottom)
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
let name = displayName.trimmingCharacters(in: .whitespaces) if !validDisplayName(displayName) {
let validName = mkValidName(name) Image(systemName: "exclamationmark.circle")
if name != validName { .foregroundColor(.red)
Button { .padding(.top, 4)
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
TextField("Enter your name", text: $displayName) textField("Display name", text: $displayName)
.focused($focusDisplayName) .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() 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() { .onAppear() {
focusDisplayName = true focusDisplayName = true
setLastVersionDefault() setLastVersionDefault()
} }
.alert(item: $alert) { a in
switch a {
case .duplicateUserError: return duplicateUserAlert
case let .createUserError(err): return creatUserErrorAlert(err)
}
}
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .leading)
.keyboardPadding() .keyboardPadding()
} }
func onboardingButtons() -> some View { func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
HStack { TextField(placeholder, text: text)
Button { .textInputAutocapitalization(.never)
hideKeyboard() .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 { withAnimation {
m.onboardingStage = .step1_SimpleXInfo onboardingStageDefault.set(.step3_CreateSimpleXAddress)
m.onboardingStage = .step3_CreateSimpleXAddress
} }
} label: {
HStack {
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
Spacer()
Button {
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
} label: {
HStack {
Text("Create")
Image(systemName: "greaterthan")
}
}
.disabled(!canCreateProfile(displayName))
}
}
private func showAlert(_ alert: UserProfileAlert) {
AlertManager.shared.showAlert(userProfileAlert(alert, $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 { } else {
showAlert(.duplicateUserError) onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
dismiss()
m.users = try listUsers()
try getUserChatData()
} }
default: } catch let error {
let err: LocalizedStringKey = "Error: \(responseError(error))" switch error as? ChatResponse {
if m.currentUser == nil { case .chatCmdError(_, .errorStore(.duplicateName)),
AlertManager.shared.showAlert(creatUserErrorAlert(err)) .chatCmdError(_, .error(.userExists)):
} else { if m.currentUser == nil {
showAlert(.createUserError(error: err)) 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)
}
} }
logger.error("Failed to create user or start chat: \(responseError(error))")
} }
logger.error("Failed to create user or start chat: \(responseError(error))")
} }
}
private func canCreateProfile(_ displayName: String) -> Bool { func canCreateProfile() -> Bool {
let name = displayName.trimmingCharacters(in: .whitespaces) displayName != "" && validDisplayName(displayName)
return name != "" && mkValidName(name) == name
}
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
switch alert {
case .duplicateUserError: return duplicateUserAlert
case let .createUserError(err): return creatUserErrorAlert(err)
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
} }
}
private var duplicateUserAlert: Alert { private var duplicateUserAlert: Alert {
Alert( Alert(
title: Text("Duplicate display name!"), title: Text("Duplicate display name!"),
message: Text("You already have a chat profile with the same display name. Please choose another name.") message: Text("You already have a chat profile with the same display name. Please choose another name.")
) )
} }
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
Alert( Alert(
title: Text("Error creating profile!"), title: Text("Error creating profile!"),
message: Text(err) 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 { func validDisplayName(_ name: String) -> Bool {
mkValidName(name.trimmingCharacters(in: .whitespaces)) == name name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#"
}
func mkValidName(_ s: String) -> String {
var c = s.cString(using: .utf8)!
return fromCString(chat_valid_name(&c)!)
} }
struct CreateProfile_Previews: PreviewProvider { struct CreateProfile_Previews: PreviewProvider {
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
Spacer() Spacer()
if let userAddress = m.userAddress { if let userAddress = m.userAddress {
SimpleXLinkQRCode(uri: userAddress.connReqContact) QRCode(uri: userAddress.connReqContact)
.frame(maxHeight: g.size.width) .frame(maxHeight: g.size.width)
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) showShareSheet(items: [userAddress.connReqContact])
} label: { } label: {
Label("Share", systemImage: "square.and.arrow.up") Label("Share", systemImage: "square.and.arrow.up")
} }
@@ -194,7 +194,7 @@ struct SendAddressMailView: View {
let messageBody = String(format: NSLocalizedString(""" let messageBody = String(format: NSLocalizedString("""
<p>Hi!</p> <p>Hi!</p>
<p><a href="%@">Connect to me via SimpleX Chat</a></p> <p><a href="%@">Connect to me via SimpleX Chat</a></p>
""", comment: "email text"), simplexChatLink(userAddress.connReqContact)) """, comment: "email text"), userAddress.connReqContact)
MailView( MailView(
isShowing: self.$showMailView, isShowing: self.$showMailView,
result: $mailViewResult, result: $mailViewResult,
@@ -14,7 +14,7 @@ struct OnboardingView: View {
var body: some View { var body: some View {
switch onboarding { switch onboarding {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateFirstProfile() case .step2_CreateProfile: CreateProfile()
case .step3_CreateSimpleXAddress: CreateSimpleXAddress() case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
case .step4_SetNotificationsMode: SetNotificationsMode() case .step4_SetNotificationsMode: SetNotificationsMode()
case .onboardingComplete: EmptyView() case .onboardingComplete: EmptyView()
@@ -251,69 +251,7 @@ private let versionDescriptions: [VersionDescription] = [
description: "- more stable message delivery.\n- a bit better groups.\n- and more!" 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!"
),
]
),
] ]
private let lastVersion = versionDescriptions.last!.version private let lastVersion = versionDescriptions.last!.version
@@ -383,15 +321,12 @@ struct WhatsNewView: View {
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View { private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
Image(systemName: icon) Image(systemName: icon).foregroundColor(.secondary)
.symbolRenderingMode(.monochrome)
.foregroundColor(.secondary)
.frame(minWidth: 30, alignment: .center) .frame(minWidth: 30, alignment: .center)
Text(title).font(.title3).bold() Text(title).font(.title3).bold()
} }
Text(description) Text(description)
.multilineTextAlignment(.leading) .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], 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()
}
+3 -20
View File
@@ -22,28 +22,10 @@ struct TerminalView: View {
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem? @State private var terminalItem: TerminalItem?
@State private var scrolled = false @State private var scrolled = false
@State private var showing = false
var body: some View { var body: some View {
if authorized { if authorized {
terminalView() terminalView()
.onAppear {
if showing { return }
showing = true
Task {
let items = await TerminalItems.shared.items()
await MainActor.run {
chatModel.terminalItems = items
chatModel.showingTerminal = true
}
}
}
.onDisappear {
if terminalItem == nil {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
} else { } else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") } Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth) .onAppear(perform: runAuth)
@@ -136,8 +118,9 @@ struct TerminalView: View {
let cmd = ChatCommand.string(composeState.message) let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
Task { DispatchQueue.main.async {
await TerminalItems.shared.addCommand(.now, cmd, resp) ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
ChatModel.shared.addTerminalItem(.resp(.now, resp))
} }
} else { } else {
DispatchQueue.global().async { DispatchQueue.global().async {
@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
} }
.disabled(currentNetCfg == NetCfg.proxyDefaults) .disabled(currentNetCfg == NetCfg.proxyDefaults)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [5_000, 10_000, 20_000, 40_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -153,9 +153,7 @@ struct AdvancedNetworkSettings: View {
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View { private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
Picker(title, selection: selection) { Picker(title, selection: selection) {
let v = selection.wrappedValue ForEach(values, id: \.self) { value in
let vs = values.contains(v) ? values : values + [v]
ForEach(vs, id: \.self) { value in
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)") Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
} }
} }
@@ -18,9 +18,10 @@ struct IncognitoHelp: View {
ScrollView { ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Group { Group {
Text("Incognito mode protects your privacy by using a new random profile for each contact.") Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.")
} }
.padding(.bottom) .padding(.bottom)
} }
@@ -71,10 +71,9 @@ struct PreferencesView: View {
do { do {
var p = fromLocalProfile(profile) var p = fromLocalProfile(profile)
p.preferences = fullPreferencesToPreferences(preferences) p.preferences = fullPreferencesToPreferences(preferences)
if let (newProfile, updatedContacts) = try await apiUpdateProfile(profile: p) { if let newProfile = try await apiUpdateProfile(profile: p) {
await MainActor.run { await MainActor.run {
chatModel.updateCurrentUser(newProfile, preferences) chatModel.updateCurrentUser(newProfile, preferences)
updatedContacts.forEach(chatModel.updateContact)
currentPreferences = preferences currentPreferences = preferences
} }
} }
@@ -13,9 +13,6 @@ struct PrivacySettings: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -64,12 +61,6 @@ struct PrivacySettings: View {
} }
Section { Section {
settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo") { settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages) Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) { .onChange(of: autoAcceptImages) {
@@ -79,23 +70,9 @@ struct PrivacySettings: View {
settingsRow("network") { settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews) Toggle("Send link previews", isOn: $useLinkPreviews)
} }
settingsRow("message") {
Toggle("Show last messages", isOn: $showChatPreviews)
}
settingsRow("rectangle.and.pencil.and.ellipsis") {
Toggle("Message draft", isOn: $saveLastDraft)
}
.onChange(of: saveLastDraft) { saveDraft in
if !saveDraft {
m.draft = nil
m.draftChatId = nil
}
}
settingsRow("link") { settingsRow("link") {
Picker("SimpleX links", selection: $simplexLinkMode) { Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach( ForEach(SimpleXLinkMode.values) { mode in
SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
) { mode in
Text(mode.text) Text(mode.text)
} }
} }
@@ -106,6 +83,10 @@ struct PrivacySettings: View {
} }
} header: { } header: {
Text("Chats") Text("Chats")
} footer: {
if case .browser = simplexLinkMode {
Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.")
}
} }
Section { Section {
@@ -119,7 +100,7 @@ struct PrivacySettings: View {
Text("Send delivery receipts to") Text("Send delivery receipts to")
} footer: { } footer: {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
Text("They can be overridden in contact and group settings.") Text("They can be overridden in contact and group settings.")
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -140,7 +121,7 @@ struct PrivacySettings: View {
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsGroups(groupReceipts, clearOverrides: false) setSendReceiptsGroups(groupReceipts, clearOverrides: false)
} }
Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) { Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsGroups(groupReceipts, clearOverrides: true) setSendReceiptsGroups(groupReceipts, clearOverrides: true)
} }
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
@@ -184,16 +165,6 @@ struct PrivacySettings: View {
} }
} }
private func setEncryptLocalFiles(_ enable: Bool) {
do {
try apiSetEncryptLocalFiles(enable)
} catch let error {
let err = responseError(error)
logger.error("apiSetEncryptLocalFiles \(err)")
alert = .error(title: "Error", error: "\(err)")
}
}
private func setOrAskSendReceiptsContacts(_ enable: Bool) { private func setOrAskSendReceiptsContacts(_ enable: Bool) {
contactReceiptsOverrides = m.chats.reduce(0) { count, chat in contactReceiptsOverrides = m.chats.reduce(0) { count, chat in
let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts
@@ -237,7 +208,7 @@ struct PrivacySettings: View {
} }
} }
} catch let error { } catch let error {
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))") alert = .error(title: "Error setting contact delivery receipts!", error: "Error: \(responseError(error))")
} }
} }
} }
@@ -285,7 +256,7 @@ struct PrivacySettings: View {
} }
} }
} catch let error { } catch let error {
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))") alert = .error(title: "Error setting group delivery receipts!", error: "Error: \(responseError(error))")
} }
} }
} }
@@ -356,7 +327,7 @@ struct SimplexLockView: View {
var id: Self { self } var id: Self { self }
} }
let laDelays: [Int] = [10, 30, 60, 180, 600, 0] let laDelays: [Int] = [10, 30, 60, 180, 0]
func laDelayText(_ t: Int) -> LocalizedStringKey { func laDelayText(_ t: Int) -> LocalizedStringKey {
let m = t / 60 let m = t / 60
@@ -378,7 +349,6 @@ struct SimplexLockView: View {
Text(mode.text) Text(mode.text)
} }
} }
.frame(height: 36)
if performLA { if performLA {
Picker("Lock after", selection: $laLockDelay) { Picker("Lock after", selection: $laLockDelay) {
let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays
@@ -386,7 +356,6 @@ struct SimplexLockView: View {
Text(laDelayText(t)) Text(laDelayText(t))
} }
} }
.frame(height: 36)
if showChangePassword && laMode == .passcode { if showChangePassword && laMode == .passcode {
Button("Change passcode") { Button("Change passcode") {
changeLAPassword() changeLAPassword()
@@ -21,10 +21,11 @@ struct ScanProtocolServer: View {
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.padding(.vertical) .padding(.vertical)
CodeScannerView(codeTypes: [.qr], completion: processQRCode) ZStack {
.aspectRatio(1, contentMode: .fit) CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.cornerRadius(12) .aspectRatio(1, contentMode: .fit)
.padding(.top) .border(.gray)
}
} }
.padding() .padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -30,8 +30,6 @@ let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
@@ -53,10 +51,6 @@ let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
let appDefaults: [String: Any] = [ let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_SHOW_LA_NOTICE: false,
@@ -71,8 +65,6 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
DEFAULT_PRIVACY_LINK_PREVIEWS: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_EXPERIMENTAL_CALLS: false, DEFAULT_EXPERIMENTAL_CALLS: false,
@@ -89,10 +81,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
] ]
enum SimpleXLinkMode: String, Identifiable { enum SimpleXLinkMode: String, Identifiable {
@@ -100,7 +89,7 @@ enum SimpleXLinkMode: String, Identifiable {
case full case full
case browser case browser
static var values: [SimpleXLinkMode] = [.description, .full] static var values: [SimpleXLinkMode] = [.description, .full, .browser]
public var id: Self { self } public var id: Self { self }
@@ -142,6 +131,7 @@ struct SettingsView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var sceneDelegate: SceneDelegate @EnvironmentObject var sceneDelegate: SceneDelegate
@Binding var showSettings: Bool @Binding var showSettings: Bool
@State private var settingsSheet: SettingsSheet?
var body: some View { var body: some View {
ZStack { ZStack {
@@ -171,6 +161,8 @@ struct SettingsView: View {
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
} }
incognitoRow()
NavigationLink { NavigationLink {
UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared) UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared)
.navigationTitle("SimpleX address") .navigationTitle("SimpleX address")
@@ -185,12 +177,6 @@ struct SettingsView: View {
} label: { } label: {
settingsRow("switch.2") { Text("Chat preferences") } settingsRow("switch.2") { Text("Chat preferences") }
} }
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer") { Text("Use from desktop") }
}
} }
.disabled(chatModel.chatRunning != true) .disabled(chatModel.chatRunning != true)
@@ -312,9 +298,38 @@ struct SettingsView: View {
} }
.navigationTitle("Your settings") .navigationTitle("Your settings")
} }
.onDisappear { .sheet(item: $settingsSheet) { sheet in
chatModel.showingTerminal = false switch sheet {
chatModel.terminalItems = [] case .incognitoInfo: IncognitoHelp()
}
}
}
@ViewBuilder private func incognitoRow() -> some View {
ZStack(alignment: .leading) {
Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
Toggle(isOn: $chatModel.incognito) {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
settingsSheet = .incognitoInfo
}
}
.onChange(of: chatModel.incognito) { incognito in
incognitoGroupDefault.set(incognito)
do {
try apiSetIncognito(incognito: incognito)
} catch {
logger.error("apiSetIncognito: cannot set incognito \(responseError(error))")
}
}
.padding(.leading, indent)
} }
} }
@@ -336,6 +351,12 @@ struct SettingsView: View {
} }
} }
private enum SettingsSheet: Identifiable {
case incognitoInfo
var id: SettingsSheet { get { self } }
}
private enum NotificationAlert { private enum NotificationAlert {
case enable case enable
case error(LocalizedStringKey, String) case error(LocalizedStringKey, String)
@@ -375,9 +396,7 @@ struct SettingsView: View {
func settingsRow<Content : View>(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View { func settingsRow<Content : View>(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color)
.symbolRenderingMode(.monochrome)
.foregroundColor(color)
content().padding(.leading, indent) content().padding(.leading, indent)
} }
} }
@@ -396,9 +415,7 @@ struct ProfilePreview: View {
Text(profileOf.displayName) Text(profileOf.displayName)
.fontWeight(.bold) .fontWeight(.bold)
.font(.title2) .font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { Text(profileOf.fullName)
Text(profileOf.fullName)
}
} }
} }
} }
@@ -190,7 +190,7 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section { Section {
MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) QRCode(uri: userAddress.connReqContact)
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
if MFMailComposeViewController.canSendMail() { if MFMailComposeViewController.canSendMail() {
shareViaEmailButton(userAddress) shareViaEmailButton(userAddress)
@@ -248,7 +248,7 @@ struct UserAddressView: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) showShareSheet(items: [userAddress.connReqContact])
} label: { } label: {
settingsRow("square.and.arrow.up") { settingsRow("square.and.arrow.up") {
Text("Share address") Text("Share address")
@@ -17,8 +17,6 @@ struct UserProfile: View {
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @State private var chosenImage: UIImage? = nil
@State private var alert: UserProfileAlert?
@FocusState private var focusDisplayName
var body: some View { var body: some View {
let user: User = chatModel.currentUser! let user: User = chatModel.currentUser!
@@ -49,27 +47,18 @@ struct UserProfile: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if !validNewProfileName(user) { if !validDisplayName(profile.displayName) {
Button { Image(systemName: "exclamationmark.circle")
alert = .invalidNameError(validName: mkValidName(profile.displayName)) .foregroundColor(.red)
} label: { .padding(.bottom, 10)
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
profileNameTextEdit("Profile name", $profile.displayName) profileNameTextEdit("Display name", $profile.displayName)
.focused($focusDisplayName)
}
.padding(.bottom)
if showFullName(user) {
profileNameTextEdit("Full name (optional)", $profile.fullName)
.padding(.bottom)
} }
profileNameTextEdit("Full name (optional)", $profile.fullName)
HStack(spacing: 20) { HStack(spacing: 20) {
Button("Cancel") { editProfile = false } Button("Cancel") { editProfile = false }
Button("Save (and notify contacts)") { saveProfile() } Button("Save (and notify contacts)") { saveProfile() }
.disabled(!canSaveProfile(user)) .disabled(profile.displayName == "" || !validDisplayName(profile.displayName))
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -85,14 +74,11 @@ struct UserProfile: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
VStack(alignment: .leading) { VStack(alignment: .leading) {
profileNameView("Profile name:", user.profile.displayName) profileNameView("Display name:", user.profile.displayName)
if showFullName(user) { profileNameView("Full name:", user.profile.fullName)
profileNameView("Full name:", user.profile.fullName)
}
Button("Edit") { Button("Edit") {
profile = fromLocalProfile(user.profile) profile = fromLocalProfile(user.profile)
editProfile = true editProfile = true
focusDisplayName = true
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -131,12 +117,14 @@ struct UserProfile: View {
profile.image = nil profile.image = nil
} }
} }
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
} }
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View { func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name) TextField(label, text: name)
.padding(.leading, 32) .textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
.padding(.leading, 28)
} }
func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View { func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View {
@@ -153,34 +141,19 @@ struct UserProfile: View {
showChooseSource = true showChooseSource = true
} }
private func validNewProfileName(_ user: User) -> Bool {
profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces))
}
private func showFullName(_ user: User) -> Bool {
user.profile.fullName != "" && user.profile.fullName != user.profile.displayName
}
private func canSaveProfile(_ user: User) -> Bool {
profile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName(user)
}
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) if let newProfile = try await apiUpdateProfile(profile: profile) {
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
DispatchQueue.main.async { DispatchQueue.main.async {
chatModel.updateCurrentUser(newProfile) chatModel.updateCurrentUser(newProfile)
profile = newProfile profile = newProfile
} }
editProfile = false
} else {
alert = .duplicateUserError
} }
} catch { } catch {
logger.error("UserProfile apiUpdateProfile error: \(responseError(error))") logger.error("UserProfile apiUpdateProfile error: \(responseError(error))")
} }
editProfile = false
} }
} }
} }
-4
View File
@@ -18,9 +18,5 @@
<array> <array>
<string>$(AppIdentifierPrefix)chat.simplex.app</string> <string>$(AppIdentifierPrefix)chat.simplex.app</string>
</array> </array>
<key>com.apple.developer.networking.multicast</key>
<true/>
<key>com.apple.developer.device-information.user-assigned-device-name</key>
<true/>
</dict> </dict>
</plist> </plist>
@@ -3630,51 +3630,6 @@ SimpleX servers cannot see your profile.</source>
<source>\~strike~</source> <source>\~strike~</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="%@ servers" xml:space="preserve" approved="no">
<source>%@ servers</source>
<target state="translated">%@ الخوادم</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
<source>%@ (current)</source>
<target state="translated">%@ (الحالي)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
<source>%@ (current):</source>
<target state="translated">%@ (الحالي):</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@:" xml:space="preserve" approved="no">
<source>%@:</source>
<target state="needs-translation">%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@ at %@:" xml:space="preserve" approved="no">
<source>%1$@ at %2$@:</source>
<target state="translated">%1$@ في %2$@:</target>
<note>copied message info, &lt;sender&gt; at &lt;time&gt;</note>
</trans-unit>
<trans-unit id="# %@" xml:space="preserve" approved="no">
<source># %@</source>
<target state="needs-translation"># %@</target>
<note>copied message info title, # &lt;title&gt;</note>
</trans-unit>
<trans-unit id="## History" xml:space="preserve" approved="no">
<source>## History</source>
<target state="translated">## السجل</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
<source>## In reply to</source>
<target state="translated">## ردًا على</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
<source>%@ and %@ connected</source>
<target state="translated">%@ و %@ متصل</target>
<note>No comment provided by engineer.</note>
</trans-unit>
</body> </body>
</file> </file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ar" datatype="plaintext"> <file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ar" datatype="plaintext">
@@ -1,15 +0,0 @@
{
"colors" : [
{
"idiom" : "universal",
"locale" : "bg"
}
],
"properties" : {
"localizable" : true
},
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "0.000",
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.533"
}
},
"idiom" : "universal"
}
],
"properties" : {
"localizable" : true
},
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,6 +0,0 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,30 +0,0 @@
/* No comment provided by engineer. */
"_italic_" = "\\_italic_";
/* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
/* No comment provided by engineer. */
"*bold*" = "\\*bold*";
/* No comment provided by engineer. */
"`a + b`" = "\\`a + b`";
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* call status */
"connecting call" = "connecting call…";
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
/* No comment provided by engineer. */
"Connecting server… (error: %@)" = "Connecting to server… (error: %@)";
/* rcv group event chat item */
"member connected" = "connected";
/* No comment provided by engineer. */
"No group!" = "Group not found!";
@@ -1,12 +0,0 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -1,12 +0,0 @@
{
"developmentRegion" : "en",
"project" : "SimpleX.xcodeproj",
"targetLocale" : "bg",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,6 @@
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */ /* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */ /* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */ /* Privacy - Photo Library Additions Usage Description */

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