diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c41fb4646a..c7ac10b9cf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,12 +5,22 @@ on:
branches:
- master
- stable
- - users
tags:
- "v*"
- "!*-fdroid"
- "!*-armv7a"
pull_request:
+ paths-ignore:
+ - "apps/ios"
+ - "apps/multiplatform"
+ - "blog"
+ - "docs"
+ - "fastlane"
+ - "images"
+ - "packages"
+ - "website"
+ - "README.md"
+ - "PRIVACY.md"
jobs:
prepare-release:
@@ -114,12 +124,12 @@ jobs:
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
+ echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
+ echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Mac
@@ -128,21 +138,21 @@ jobs:
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
+ echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
+ echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- - name: Install pkg-config for Mac
+ - name: Install openssl for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
- run: brew install pkg-config
+ run: brew install openssl@3.0
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
@@ -324,7 +334,7 @@ jobs:
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/prepare-openssl-windows.sh
- openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
+ openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | 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
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 7fc66308f8..6839d48aeb 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -10,6 +10,7 @@ on:
- blog/**
- docs/**
- .github/workflows/web.yml
+ - PRIVACY.md
jobs:
build:
diff --git a/.gitignore b/.gitignore
index e3ea5d267b..645b55ec9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,7 @@ website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
+result
# Ignore API documentation
website/api-docs/
diff --git a/Dockerfile b/Dockerfile
index 6c60195f97..7b9641777a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
-RUN cabal build exe:simplex-chat
+RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
diff --git a/PRIVACY.md b/PRIVACY.md
index 66dff0e807..7c4bfbf660 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -3,63 +3,93 @@ layout: layouts/privacy.html
permalink: /privacy/index.html
---
-# SimpleX Chat Privacy Policy and Conditions of Use
+# SimpleX Chat Operators Privacy Policy and Conditions of Use
-SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
+## Summary
-SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
+[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
+
+[Privacy policy](#privacy-policy) covers:
+- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
+- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
+- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
+- [how users connect](#connections-with-other-users) without any user profile identifiers.
+- [iOS push notifications](#ios-push-notifications) privacy limitations.
+- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
+- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
+- [source code license](#source-code-license) and [updates to this document](#updates).
+
+[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
+
+*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
+
+## Introduction
+
+SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
+
+SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
-If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
-SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
+### General principles
-SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
+SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
-SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
+SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
-While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
+SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
-We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
+SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
-SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
+
+The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+
+The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits – see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
### Your information
#### User profiles
-Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
+Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
-When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
+When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
#### Messages and Files
-SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
+SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages – you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
-You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
+#### Private message delivery
-The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
+You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
+
+In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
+
+You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
+
+*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
+
+#### Storage of messages and files on the servers
+
+The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
-If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
-
-As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
-
-In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
+The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
#### Connections with other users
-When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
+When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default.
-SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
+Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy
@@ -75,6 +105,8 @@ You can always safely replace the initial part of the link `https://simplex.chat
#### iOS Push Notifications
+This section applies only to the notification servers operated by SimpleX Chat Ltd.
+
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
@@ -83,93 +115,111 @@ You can read more about the design of iOS push notifications [here](./blog/20220
#### 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 design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
+Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
#### SimpleX Directory
+This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
+
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
#### User Support
-If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
+The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
-### Information we may share
+### Preset Server Operators
-SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
+Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
-We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+Preset server operators will not provide general access to their servers or the data on their servers to each other.
-The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
+Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers.
+
+### Information Preset Server Operators May Share
+
+The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
+
+SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+
+The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
-- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
+- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
-At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
+At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
-We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+
+### Source code license
+
+As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
+
+In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates
-We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
+This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
-Please also read our Conditions of Use of Software and Infrastructure below.
+This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy.
-If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+Please also read The Conditions of Use of Software and Infrastructure below.
+
+If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure
-You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
+You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not.
-**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
+**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country.
-**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
+**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
-**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
+**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
-**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
+**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
-**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
+**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
-**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
+**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages.
-**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
+**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way.
-**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
+**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes.
-**Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
+**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
-**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
+**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
-**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts.
+**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
-**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
+**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
-**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
+**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
-**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
+**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
-**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
+**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
-**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
+**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
-**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
+**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
-**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
-**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
+**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time.
-**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
+**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions.
-**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
+**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications.
-**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
+**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat.
-**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
+**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
-Updated April 24, 2024
+Updated November 14, 2024
diff --git a/README.md b/README.md
index 29ee7e189e..ad70c350e4 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
-5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
+5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
[Learn more about SimpleX Chat](#contents).
@@ -72,7 +72,7 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
-You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
-[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
+[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
-## Help us with donations
+## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -233,32 +233,24 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
+[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
+
+[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
+
+[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
+
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
-[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
-
-[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
-
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
-[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
-
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
-[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
-
-[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
-
-[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
-
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
-[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
-
[All updates](./blog)
## :zap: Quick installation of a terminal app
@@ -298,25 +290,27 @@ What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
-4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
-5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
-6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
-7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
-8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
-9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
-10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
-11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
-12. Manual messaging queue rotations to move conversation to another SMP relay.
-13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
-14. Local files encryption.
+4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
+5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
+6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
+7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
+8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
+9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
+10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
+11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
+12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
+13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
+14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
+15. Manual messaging queue rotations to move conversation to another SMP relay.
+16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
+17. Local files encryption.
We plan to add:
-1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
-2. Post-quantum resistant key exchange in double ratchet protocol.
-3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
-4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
-5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
+1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
+2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
+3. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
+4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers
@@ -384,9 +378,11 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
+- ✅ Support multiple network operators in the app.
+- 🏗 Large groups, communities and public channels.
+- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users.
-- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
@@ -405,7 +401,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
-The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+
+The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift
index d52c950d81..ad8c661e1c 100644
--- a/apps/ios/Shared/AppDelegate.swift
+++ b/apps/ios/Shared/AppDelegate.swift
@@ -15,35 +15,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
- if #available(iOS 17.0, *) { trackKeyboard() }
- NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
prepareForLaunch()
+ deleteOldChatArchive()
return true
}
- @available(iOS 17.0, *)
- private func trackKeyboard() {
- NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
- NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
- }
-
- @available(iOS 17.0, *)
- @objc func keyboardWillShow(_ notification: Notification) {
- if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
- ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
- }
- }
-
- @available(iOS 17.0, *)
- @objc func keyboardWillHide(_ notification: Notification) {
- ChatModel.shared.keyboardHeight = 0
- }
-
- @objc func pasteboardChanged() {
- ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
- }
-
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json
new file mode 100644
index 0000000000..8e38b499dd
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "symbols": [
+ {
+ "filename": "checkmark.2.svg",
+ "idiom": "universal"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg
new file mode 100644
index 0000000000..577fa1db76
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg
@@ -0,0 +1,227 @@
+
+
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json
new file mode 100644
index 0000000000..11a91cb811
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "symbols": [
+ {
+ "filename": "checkmark.wide.svg",
+ "idiom": "universal"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg
new file mode 100644
index 0000000000..b5dfc6b3de
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg
@@ -0,0 +1,218 @@
+
+
\ No newline at end of file
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json
new file mode 100644
index 0000000000..d3a15f9a33
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_logo_blue_white.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png
new file mode 100644
index 0000000000..e1d6dda4fe
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json
new file mode 100644
index 0000000000..ad18e60448
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_logo_blue.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png
new file mode 100644
index 0000000000..87f1373d75
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png differ
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json
new file mode 100644
index 0000000000..16686bdf80
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Flux_symbol_blue-white.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png
new file mode 100644
index 0000000000..0793b0ee85
Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png differ
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index d93625986e..652258415e 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -9,10 +9,23 @@ import SwiftUI
import Intents
import SimpleXChat
+private enum NoticesSheet: Identifiable {
+ case whatsNew(updatedConditions: Bool)
+ case updatedConditions
+
+ var id: String {
+ switch self {
+ case .whatsNew: return "whatsNew"
+ case .updatedConditions: return "updatedConditions"
+ }
+ }
+}
+
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
+ @ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
@@ -29,14 +42,15 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
- @State private var showSettings = false
- @State private var showWhatsNew = false
+ @State private var noticesShown = false
+ @State private var noticesSheetItem: NoticesSheet? = nil
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil
+ @State private var chatListUserPickerSheet: UserPickerSheet? = nil
- private let callTopPadding: CGFloat = 50
+ private let callTopPadding: CGFloat = 40
private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -86,7 +100,7 @@ struct ContentView: View {
callView(call)
}
- if !showSettings, let la = chatModel.laRequest {
+ if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
LocalAuthView(authRequest: la)
.onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
@@ -109,9 +123,6 @@ struct ContentView: View {
}
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
- .sheet(isPresented: $showSettings) {
- SettingsView(showSettings: $showSettings)
- }
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true }
@@ -151,12 +162,12 @@ struct ContentView: View {
}
}
.onAppear {
- reactOnDarkThemeChanges()
+ reactOnDarkThemeChanges(systemInDarkThemeCurrently)
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
- reactOnDarkThemeChanges()
+ reactOnDarkThemeChanges(scheme == .dark)
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
@@ -207,7 +218,7 @@ struct ContentView: View {
CallDuration(call: call)
}
.padding(.horizontal)
- .frame(height: callTopPadding - 10)
+ .frame(height: callTopPadding)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
@@ -253,7 +264,8 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
- ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
+ ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
+ .redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
@@ -262,8 +274,15 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- if !showWhatsNew {
- showWhatsNew = shouldShowWhatsNew()
+ if !noticesShown {
+ let showWhatsNew = shouldShowWhatsNew()
+ let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
+ noticesShown = showWhatsNew || showUpdatedConditions
+ if showWhatsNew {
+ noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
+ } else if showUpdatedConditions {
+ noticesSheetItem = .updatedConditions
+ }
}
}
}
@@ -271,8 +290,22 @@ struct ContentView: View {
connectViaUrl()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
- .sheet(isPresented: $showWhatsNew) {
- WhatsNewView()
+ .sheet(item: $noticesSheetItem) { item in
+ switch item {
+ case let .whatsNew(updatedConditions):
+ WhatsNewView(updatedConditions: updatedConditions)
+ .modifier(ThemedBackground())
+ .if(updatedConditions) { v in
+ v.task { await setConditionsNotified_() }
+ }
+ case .updatedConditions:
+ UsageConditionsView(
+ currUserServers: Binding.constant([]),
+ userServers: Binding.constant([])
+ )
+ .modifier(ThemedBackground(grouped: true))
+ .task { await setConditionsNotified_() }
+ }
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()
@@ -284,6 +317,15 @@ struct ContentView: View {
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
}
+ private func setConditionsNotified_() async {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ try await setConditionsNotified(conditionsId: conditionsId)
+ } catch let error {
+ logger.error("setConditionsNotified error: \(responseError(error))")
+ }
+ }
+
private func processUserActivity(_ activity: NSUserActivity) {
let intent = activity.interaction?.intent
if let intent = intent as? INStartCallIntent {
@@ -300,9 +342,18 @@ struct ContentView: View {
if let contactId = contacts?.first?.personHandle?.value,
let chat = chatModel.getChat(contactId),
case let .direct(contact) = chat.chatInfo {
- logger.debug("callToRecentContact: schedule call")
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- CallController.shared.startCall(contact, mediaType)
+ let activeCall = chatModel.activeCall
+ // This line works when a user clicks on a video button in CallKit UI while in call.
+ // The app tries to make another call to the same contact and overwite activeCall instance making its state broken
+ if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
+ Task {
+ await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
+ }
+ } else if activeCall == nil {
+ logger.debug("callToRecentContact: schedule call")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ CallController.shared.startCall(contact, mediaType)
+ }
}
}
}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 347ffa7a8f..6b6b0ac03f 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -61,7 +61,7 @@ class ItemsModel: ObservableObject {
init() {
publisher
- .throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
+ .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
@@ -123,6 +123,14 @@ class NetworkModel: ObservableObject {
}
}
+/// ChatItemWithMenu can depend on previous or next item for it's appearance
+/// This dummy model is used to force an update of all chat items,
+/// when they might have changed appearance.
+class ChatItemDummyModel: ObservableObject {
+ static let shared = ChatItemDummyModel()
+ func sendUpdate() { objectWillChange.send() }
+}
+
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -139,11 +147,12 @@ final class ChatModel: ObservableObject {
@Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false
+ @Published var notificationResponse: UNNotificationResponse?
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest?
// list of chat "previews"
- @Published var chats: [Chat] = []
+ @Published private(set) var chats: [Chat] = []
@Published var deletedChats: Set = []
// current chat
@Published var chatId: String?
@@ -183,10 +192,9 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
@Published var draftChatId: String?
- // tracks keyboard height via subscription in AppDelegate
- @Published var keyboardHeight: CGFloat = 0
- @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
+ // usage conditions
+ @Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary Void> = [:]
@@ -359,25 +367,8 @@ final class ChatModel: ObservableObject {
}
}
- func updateChats(with newChats: [ChatData]) {
- for i in 0.. Bool {
if let i = getChatItemIndex(cItem) {
- withConditionalAnimation {
- _updateChatItem(at: i, with: cItem)
- }
+ _updateChatItem(at: i, with: cItem)
+ ChatItemDummyModel.shared.sendUpdate()
return false
} else {
- withConditionalAnimation(itemAnimation()) {
- var ci = cItem
- if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
- ci.meta.itemStatus = status
- }
- im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
- im.itemAdded = true
+ var ci = cItem
+ if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
+ ci.meta.itemStatus = status
}
+ im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
+ im.itemAdded = true
+ ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -540,7 +529,7 @@ final class ChatModel: ObservableObject {
}
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
- guard var current = currentUser else { return }
+ guard var current = currentUser, current.uiThemes != uiThemes else { return }
current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i {
@@ -576,6 +565,7 @@ final class ChatModel: ObservableObject {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
+ self.updateFloatingButtons(unreadCount: 0)
chat.chatStats = ChatStats()
}
// update current chat
@@ -592,6 +582,12 @@ final class ChatModel: ObservableObject {
}
}
+ private func updateFloatingButtons(unreadCount: Int) {
+ let fbm = ChatView.FloatingButtonModel.shared
+ fbm.totalUnread = unreadCount
+ fbm.objectWillChange.send()
+ }
+
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
@@ -610,6 +606,7 @@ final class ChatModel: ObservableObject {
if markedCount > 0 {
chat.chatStats.unreadCount -= markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
+ self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
}
}
@@ -639,19 +636,15 @@ final class ChatModel: ObservableObject {
}
}
- func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
- if chatId == cInfo.id,
- let itemIndex = getChatItemIndex(cItem),
- im.reversedChatItems[itemIndex].isRcvNew {
- await MainActor.run {
- withTransaction(Transaction()) {
- // update current chat
- markChatItemRead_(itemIndex)
- // update preview
- unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
+ func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
+ if self.chatId == cInfo.id {
+ for itemId in itemIds {
+ if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
+ markChatItemRead_(i)
}
}
}
+ self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
}
private let unreadCollector = UnreadCollector()
@@ -677,9 +670,10 @@ final class ChatModel: ObservableObject {
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
- DispatchQueue.main.async {
- self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
+ if chatId == ChatModel.shared.chatId {
+ ChatView.FloatingButtonModel.shared.totalUnread += count
}
+ self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
subject.send()
}
}
@@ -851,7 +845,7 @@ final class ChatModel: ObservableObject {
}
func dismissConnReqView(_ id: String) {
- if id == showingInvitation?.connId {
+ if id == showingInvitation?.pcc.id {
markShowingInvitationUsed()
dismissAllSheets()
}
@@ -901,39 +895,10 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember)
}
}
-
- func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts {
- var i = 0
- var totalBelow = 0
- var unreadBelow = 0
- while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
- totalBelow += 1
- if im.reversedChatItems[i].isRcvNew {
- unreadBelow += 1
- }
- i += 1
- }
- return UnreadChatItemCounts(
- // TODO these thresholds account for the fact that items are still "visible" while
- // covered by compose area, they should be replaced with the actual height in pixels below the screen.
- isNearBottom: totalBelow < 15,
- isReallyNearBottom: totalBelow < 2,
- unreadBelow: unreadBelow
- )
- }
-
- func topItemInView(itemsInView: Set) -> ChatItem? {
- let maxIx = im.reversedChatItems.count - 1
- var i = 0
- let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
- while i < maxIx && !inView(i) { i += 1 }
- while i < maxIx && inView(i) { i += 1 }
- return im.reversedChatItems[min(i - 1, maxIx)]
- }
}
struct ShowingInvitation {
- var connId: String
+ var pcc: PendingContactConnection
var connChatUsed: Bool
}
@@ -942,12 +907,6 @@ struct NTFContactRequest {
var chatId: String
}
-struct UnreadChatItemCounts: Equatable {
- var isNearBottom: Bool
- var isReallyNearBottom: Bool
- var unreadBelow: Int
-}
-
final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index 95063845f1..6c33031eeb 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -26,20 +26,37 @@ enum NtfCallAction {
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager()
+ public var navigatingToChat = false
private var granted = false
private var prevNtfTime: Dictionary = [:]
+ override init() {
+ super.init()
+ UNUserNotificationCenter.current().delegate = self
+ }
+
// Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive")
- let content = response.notification.request.content
+ if appStateGroupDefault.get() == .active {
+ processNotificationResponse(response)
+ } else {
+ logger.debug("NtfManager.userNotificationCenter: remember response in model")
+ ChatModel.shared.notificationResponse = response
+ }
+ handler()
+ }
+
+ func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared
- let action = response.actionIdentifier
- logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
+ let content = ntfResponse.notification.request.content
+ let action = ntfResponse.actionIdentifier
+ logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId {
+ logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@@ -58,10 +75,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
} else {
if let chatId = content.targetContentIdentifier {
- ItemsModel.shared.loadOpenChat(chatId)
+ self.navigatingToChat = true
+ ItemsModel.shared.loadOpenChat(chatId) {
+ self.navigatingToChat = false
+ }
}
}
- handler()
}
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -76,7 +95,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil
}
-
// Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -185,6 +203,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
+ ),
+ UNNotificationCategory(
+ identifier: ntfCategoryManyEvents,
+ actions: [],
+ intentIdentifiers: [],
+ hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
)
])
}
@@ -210,29 +234,28 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
}
}
- center.delegate = self
}
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
- addNotification(createContactRequestNtf(user, contactRequest))
+ addNotification(createContactRequestNtf(user, contactRequest, 0))
}
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
- addNotification(createContactConnectedNtf(user, contact))
+ addNotification(createContactConnectedNtf(user, contact, 0))
}
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled {
- addNotification(createMessageReceivedNtf(user, cInfo, cItem))
+ addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
- addNotification(createCallInvitationNtf(invitation))
+ addNotification(createCallInvitationNtf(invitation, 0))
}
func setNtfBadgeCount(_ count: Int) {
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 76d043b2aa..51be3191ec 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -112,9 +112,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp
}
-func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
+func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
await withCheckedContinuation { cont in
- cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
+ cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
}
}
@@ -332,13 +332,15 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r
}
-func loadChat(chat: Chat, search: String = "") async {
+func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
- await MainActor.run { im.reversedChatItems = [] }
+ if clearItems {
+ await MainActor.run { im.reversedChatItems = [] }
+ }
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
@@ -355,17 +357,23 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r
}
-func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
- let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
+func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
+ let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
+ if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
+ throw r
+}
+
+func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
-func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
- let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
+func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
-private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
+private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
let chatModel = ChatModel.shared
let r: ChatResponse
if toChatType == .direct {
@@ -378,10 +386,13 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
}
})
r = await chatSendCmd(cmd, bgTask: false)
- if case let .newChatItem(_, aChatItem) = r {
- cItem = aChatItem.chatItem
- chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
- return cItem
+ if case let .newChatItems(_, aChatItems) = r {
+ let cItems = aChatItems.map { $0.chatItem }
+ if let cItemLast = cItems.last {
+ cItem = cItemLast
+ chatModel.messageDelivery[cItemLast.id] = endTask
+ }
+ return cItems
}
if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert)
@@ -392,18 +403,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return nil
} else {
r = await chatSendCmd(cmd, bgDelay: msgDelay)
- if case let .newChatItem(_, aChatItem) = r {
- return aChatItem.chatItem
+ if case let .newChatItems(_, aChatItems) = r {
+ return aChatItems.map { $0.chatItem }
}
sendMessageErrorAlert(r)
return nil
}
}
-func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
- let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
- if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
- createChatItemErrorAlert(r)
+func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
+ if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
+ createChatItemsErrorAlert(r)
return nil
}
@@ -415,8 +426,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
)
}
-private func createChatItemErrorAlert(_ r: ChatResponse) {
- logger.error("apiCreateChatItem error: \(String(describing: r))")
+private func createChatItemsErrorAlert(_ r: ChatResponse) {
+ logger.error("apiCreateChatItems error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(responseError(r))"
@@ -435,6 +446,13 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r
}
+func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
+ let userId = try currentUserId("apiGetReactionMemebers")
+ let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
+ if case let .reactionMembers(_, memberReactions) = r { return memberReactions }
+ throw r
+}
+
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
@@ -451,7 +469,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String
let r = chatSendCmdSync(.apiGetNtfToken)
switch r {
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
- case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
+ case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil)
@@ -489,18 +507,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
-func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
- let userId = try currentUserId("getUserProtoServers")
- let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
- if case let .userProtoServers(_, servers) = r { return servers }
- throw r
-}
-
-func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
- let userId = try currentUserId("setUserProtoServers")
- try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
-}
-
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
@@ -513,6 +519,72 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
throw r
}
+func getServerOperators() async throws -> ServerOperatorConditions {
+ let r = await chatSendCmd(.apiGetServerOperators)
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("getServerOperators error: \(String(describing: r))")
+ throw r
+}
+
+func getServerOperatorsSync() throws -> ServerOperatorConditions {
+ let r = chatSendCmdSync(.apiGetServerOperators)
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("getServerOperators error: \(String(describing: r))")
+ throw r
+}
+
+func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
+ let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("setServerOperators error: \(String(describing: r))")
+ throw r
+}
+
+func getUserServers() async throws -> [UserOperatorServers] {
+ let userId = try currentUserId("getUserServers")
+ let r = await chatSendCmd(.apiGetUserServers(userId: userId))
+ if case let .userServers(_, userServers) = r { return userServers }
+ logger.error("getUserServers error: \(String(describing: r))")
+ throw r
+}
+
+func setUserServers(userServers: [UserOperatorServers]) async throws {
+ let userId = try currentUserId("setUserServers")
+ let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
+ if case .cmdOk = r { return }
+ logger.error("setUserServers error: \(String(describing: r))")
+ throw r
+}
+
+func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
+ let userId = try currentUserId("validateServers")
+ let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
+ if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
+ logger.error("validateServers error: \(String(describing: r))")
+ throw r
+}
+
+func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
+ let r = await chatSendCmd(.apiGetUsageConditions)
+ if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
+ logger.error("getUsageConditions error: \(String(describing: r))")
+ throw r
+}
+
+func setConditionsNotified(conditionsId: Int64) async throws {
+ let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
+ if case .cmdOk = r { return }
+ logger.error("setConditionsNotified error: \(String(describing: r))")
+ throw r
+}
+
+func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
+ let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
+ if case let .serverOperatorConditions(conditions) = r { return conditions }
+ logger.error("acceptConditions error: \(String(describing: r))")
+ throw r
+}
+
func getChatItemTTL() throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTL")
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
@@ -574,19 +646,25 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi
throw r
}
-func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
+func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
throw r
}
-func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
+func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) {
+ let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
+ throw r
+}
+
+func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
-func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
+func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
@@ -634,8 +712,8 @@ func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
throw r
}
-func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
- let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
+func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) {
+ let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) }
throw r
}
@@ -671,6 +749,13 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r
}
+func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
+ let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
+
+ if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
+ throw r
+}
+
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
@@ -719,6 +804,12 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
return (nil, alert)
+ case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
+ let alert = mkAlert(
+ title: "Undelivered messages",
+ message: "The connection reached the limit of undelivered messages, your contact may be offline."
+ )
+ return (nil, alert)
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
if internalErr == "SEUniqueID" {
let alert = mkAlert(
@@ -984,8 +1075,12 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
throw r
}
-func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
- try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
+func apiChatRead(type: ChatType, id: Int64) async throws {
+ try await sendCommandOkResp(.apiChatRead(type: type, id: id))
+}
+
+func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
+ try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@@ -1023,77 +1118,122 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
}
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
- if let chatItem = await apiReceiveFile(
- fileId: fileId,
- userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
- encrypted: privacyEncryptLocalFilesGroupDefault.get(),
+ await receiveFiles(
+ user: user,
+ fileIds: [fileId],
+ userApprovedRelays: userApprovedRelays,
auto: auto
- ) {
- await chatItemSimpleUpdate(user, chatItem)
- }
+ )
}
-func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
- let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
- let am = AlertManager.shared
- if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
- if case .rcvFileAcceptedSndCancelled = r {
- logger.debug("apiReceiveFile error: sender cancelled file transfer")
- if !auto {
- am.showAlertMsg(
- title: "Cannot receive file",
- message: "Sender cancelled file transfer."
+func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
+ var fileIdsToApprove = [Int64]()
+ var srvsToApprove = Set()
+ var otherFileErrs = [ChatResponse]()
+
+ for fileId in fileIds {
+ let r = await chatSendCmd(
+ .receiveFile(
+ fileId: fileId,
+ userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
+ encrypted: privacyEncryptLocalFilesGroupDefault.get(),
+ inline: nil
)
+ )
+ switch r {
+ case let .rcvFileAccepted(_, chatItem):
+ await chatItemSimpleUpdate(user, chatItem)
+ default:
+ if let chatError = chatError(r) {
+ switch chatError {
+ case let .fileNotApproved(fileId, unknownServers):
+ fileIdsToApprove.append(fileId)
+ srvsToApprove.formUnion(unknownServers)
+ default:
+ otherFileErrs.append(r)
+ }
+ }
}
- } else if let networkErrorAlert = networkErrorAlert(r) {
- logger.error("apiReceiveFile network error: \(String(describing: r))")
- if !auto {
- am.showAlert(networkErrorAlert)
+ }
+
+ if !auto {
+ let otherErrsStr = if otherFileErrs.isEmpty {
+ ""
+ } else if otherFileErrs.count == 1 {
+ "\(otherFileErrs[0])"
+ } else if otherFileErrs.count == 2 {
+ "\(otherFileErrs[0])\n\(otherFileErrs[1])"
+ } else {
+ "\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)"
}
- } else {
- switch chatError(r) {
- case .fileCancelled:
- logger.debug("apiReceiveFile ignoring fileCancelled error")
- case .fileAlreadyReceiving:
- logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
- case let .fileNotApproved(fileId, unknownServers):
- logger.debug("apiReceiveFile fileNotApproved error")
- if !auto {
- let srvs = unknownServers.map { s in
+
+ // If there are not approved files, alert is shown the same way both in case of singular and plural files reception
+ if !fileIdsToApprove.isEmpty {
+ let srvs = srvsToApprove
+ .map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
- am.showAlert(Alert(
- title: Text("Unknown servers!"),
- message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
- primaryButton: .default(
- Text("Download"),
- action: {
- Task {
- logger.debug("apiReceiveFile fileNotApproved alert - in Task")
- if let user = ChatModel.shared.currentUser {
- await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
- }
+ .sorted()
+ .joined(separator: ", ")
+ let fIds = fileIdsToApprove
+ await MainActor.run {
+ showAlert(
+ title: NSLocalizedString("Unknown servers!", comment: "alert title"),
+ message: (
+ String.localizedStringWithFormat(NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.", comment: "alert message"), srvs) +
+ (otherErrsStr != "" ? "\n\n" + String.localizedStringWithFormat(NSLocalizedString("Other file errors:\n%@", comment: "alert message"), otherErrsStr) : "")
+ ),
+ buttonTitle: NSLocalizedString("Download", comment: "alert button"),
+ buttonAction: {
+ Task {
+ logger.debug("apiReceiveFile fileNotApproved alert - in Task")
+ if let user = ChatModel.shared.currentUser {
+ await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true)
}
}
- ),
- secondaryButton: .cancel()
- ))
+ },
+ cancelButton: true
+ )
}
- default:
- logger.error("apiReceiveFile error: \(String(describing: r))")
- if !auto {
- am.showAlertMsg(
- title: "Error receiving file",
- message: "Error: \(responseError(r))"
+ } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
+ let errorResponse = otherFileErrs.first!
+ switch errorResponse {
+ case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
+ logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Cannot receive file", comment: "alert title"),
+ message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
+ )
+ }
+ default:
+ if let chatError = chatError(errorResponse) {
+ switch chatError {
+ case .fileCancelled, .fileAlreadyReceiving:
+ logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
+ default:
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error receiving file", comment: "alert title"),
+ message: responseError(errorResponse)
+ )
+ }
+ }
+ }
+ }
+ } else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error receiving file", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("File errors:\n%@", comment: "alert message"), otherErrsStr)
)
}
}
}
- return nil
}
func cancelFile(user: User, fileId: Int64) async {
@@ -1216,12 +1356,18 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact))
}
-func apiGetCallInvitations() throws -> [RcvCallInvitation] {
+func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
let r = chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
+func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
+ let r = await chatSendCmd(.apiGetCallInvitations)
+ if case let .callInvitations(invs) = r { return invs }
+ throw r
+}
+
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
@@ -1236,15 +1382,13 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
throw r
}
-func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
+func markChatRead(_ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
- let minItemId = chat.chatStats.minUnreadItemId
- let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
- try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
+ try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
- withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
+ withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
}
}
if chat.chatStats.unreadChat {
@@ -1267,13 +1411,14 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
-func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
+func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
do {
- logger.debug("apiMarkChatItemRead: \(cItem.id)")
- try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
- await ChatModel.shared.markChatItemRead(cInfo, cItem)
+ try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
+ DispatchQueue.main.async {
+ ChatModel.shared.markChatItemsRead(cInfo, itemIds)
+ }
} catch {
- logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
+ logger.error("apiChatItemsRead error: \(responseError(error))")
}
}
@@ -1418,9 +1563,9 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r
}
-func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
+func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
- let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
+ let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
@@ -1461,6 +1606,16 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
+ m.conditions = try getServerOperatorsSync()
+ if shouldImportAppSettingsDefault.get() {
+ do {
+ let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
+ appSettings.importIntoApp()
+ shouldImportAppSettingsDefault.set(false)
+ } catch {
+ logger.error("Error while importing app settings: \(error)")
+ }
+ }
if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true)
@@ -1505,7 +1660,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
}
}
-func startChat(refreshInvitations: Bool = true) throws {
+func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
@@ -1515,7 +1670,7 @@ func startChat(refreshInvitations: Bool = true) throws {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
- try refreshCallInvitations()
+ Task { try await refreshCallInvitations() }
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
@@ -1524,13 +1679,15 @@ func startChat(refreshInvitations: Bool = true) throws {
if let token = m.deviceToken {
registerToken(token: token)
}
- withAnimation {
- let savedOnboardingStage = onboardingStageDefault.get()
- m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
- ? .step3_CreateSimpleXAddress
- : savedOnboardingStage
- if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
- m.setDeliveryReceipts = true
+ if !onboarding {
+ withAnimation {
+ let savedOnboardingStage = onboardingStageDefault.get()
+ m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
+ ? .step3_ChooseServerOperators
+ : savedOnboardingStage
+ if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
+ m.setDeliveryReceipts = true
+ }
}
}
}
@@ -1589,8 +1746,7 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
- m.chats = chats.map { Chat.init($0) }
- m.popChatCollector.clear()
+ m.updateChats(chats)
}
private func getUserChatDataAsync() async throws {
@@ -1602,14 +1758,12 @@ private func getUserChatDataAsync() async throws {
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
- m.chats = chats.map { Chat.init($0) }
- m.popChatCollector.clear()
+ m.updateChats(chats)
}
} else {
await MainActor.run {
m.userAddress = nil
- m.chats = []
- m.popChatCollector.clear()
+ m.updateChats([])
}
}
}
@@ -1770,36 +1924,45 @@ func processReceivedMsg(_ res: ChatResponse) async {
n.networkStatuses = ns
}
}
- case let .newChatItem(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- await MainActor.run {
- if active(user) {
- m.addChatItem(cInfo, cItem)
- } else if cItem.isRcvNew && cInfo.ntfsEnabled {
- m.increaseUnreadCounter(user: user)
+ case let .newChatItems(user, chatItems):
+ for chatItem in chatItems {
+ let cInfo = chatItem.chatInfo
+ let cItem = chatItem.chatItem
+ await MainActor.run {
+ if active(user) {
+ m.addChatItem(cInfo, cItem)
+ } else if cItem.isRcvNew && cInfo.ntfsEnabled {
+ m.increaseUnreadCounter(user: user)
+ }
+ }
+ if let file = cItem.autoReceiveFile() {
+ Task {
+ await receiveFile(user: user, fileId: file.fileId, auto: true)
+ }
+ }
+ if cItem.showNotification {
+ NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
- if let file = cItem.autoReceiveFile() {
- Task {
- await receiveFile(user: user, fileId: file.fileId, auto: true)
+ case let .chatItemsStatusesUpdated(user, chatItems):
+ for chatItem in chatItems {
+ let cInfo = chatItem.chatInfo
+ let cItem = chatItem.chatItem
+ if !cItem.isDeletedContent && active(user) {
+ await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
}
- }
- if cItem.showNotification {
- NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
- }
- case let .chatItemStatusUpdated(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if !cItem.isDeletedContent && active(user) {
- await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
- }
- if let endTask = m.messageDelivery[cItem.id] {
- switch cItem.meta.itemStatus {
- case .sndSent: endTask()
- case .sndErrorAuth: endTask()
- case .sndError: endTask()
- default: ()
+ if let endTask = m.messageDelivery[cItem.id] {
+ switch cItem.meta.itemStatus {
+ case .sndNew: ()
+ case .sndSent: endTask()
+ case .sndRcvd: endTask()
+ case .sndErrorAuth: endTask()
+ case .sndError: endTask()
+ case .sndWarning: endTask()
+ case .rcvNew: ()
+ case .rcvRead: ()
+ case .invalid: ()
+ }
}
}
case let .chatItemUpdated(user, aChatItem):
@@ -1858,6 +2021,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(hostConn.id)
}
}
+ case let .businessLinkConnecting(user, groupInfo, _, fromContact):
+ if !active(user) { return }
+
+ await MainActor.run {
+ m.updateGroup(groupInfo)
+ }
+ if m.chatId == fromContact.id {
+ ItemsModel.shared.loadOpenChat(groupInfo.id)
+ }
+ await MainActor.run {
+ m.removeChat(fromContact.id)
+ }
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
@@ -1993,7 +2168,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
- call.peerMedia = callType.media
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -2162,32 +2336,42 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
}
}
-func refreshCallInvitations() throws {
+func refreshCallInvitations() async throws {
let m = ChatModel.shared
- let callInvitations = try justRefreshCallInvitations()
- if let (chatId, ntfAction) = m.ntfCallInvitationAction,
- let invitation = m.callInvitations.removeValue(forKey: chatId) {
- m.ntfCallInvitationAction = nil
- CallController.shared.callAction(invitation: invitation, action: ntfAction)
- } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
- activateCall(invitation)
+ let callInvitations = try await apiGetCallInvitations()
+ await MainActor.run {
+ m.callInvitations = callsByChat(callInvitations)
+ if let (chatId, ntfAction) = m.ntfCallInvitationAction,
+ let invitation = m.callInvitations.removeValue(forKey: chatId) {
+ m.ntfCallInvitationAction = nil
+ CallController.shared.callAction(invitation: invitation, action: ntfAction)
+ } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
+ activateCall(invitation)
+ }
}
}
-func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
- let m = ChatModel.shared
- let callInvitations = try apiGetCallInvitations()
- m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
- return callInvitations
+func justRefreshCallInvitations() async throws {
+ let callInvitations = try apiGetCallInvitationsSync()
+ await MainActor.run {
+ ChatModel.shared.callInvitations = callsByChat(callInvitations)
+ }
+}
+
+private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
+ callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
+ result, inv in result[inv.contact.id] = inv
+ }
}
func activateCall(_ callInvitation: RcvCallInvitation) {
- if !callInvitation.user.showNotifications { return }
let m = ChatModel.shared
+ logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
+ if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
DispatchQueue.main.async {
- m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
+ m.callInvitations[callInvitation.contact.id]?.callUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index fe306b944f..10120db185 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -82,10 +82,18 @@ struct SimpleXApp: App {
if appState != .stopped {
startChatAndActivate {
- if appState.inactive && chatModel.chatRunning == true {
- updateChats()
- if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
- updateCallInvitations()
+ if chatModel.chatRunning == true {
+ if let ntfResponse = chatModel.notificationResponse {
+ chatModel.notificationResponse = nil
+ NtfManager.shared.processNotificationResponse(ntfResponse)
+ }
+ if appState.inactive {
+ Task {
+ await updateChats()
+ if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
+ await updateCallInvitations()
+ }
+ }
}
}
}
@@ -130,16 +138,17 @@ struct SimpleXApp: App {
}
}
- private func updateChats() {
+ private func updateChats() async {
do {
- let chats = try apiGetChats()
- chatModel.updateChats(with: chats)
+ let chats = try await apiGetChatsAsync()
+ await MainActor.run { chatModel.updateChats(chats) }
if let id = chatModel.chatId,
- let chat = chatModel.getChat(id) {
- Task { await loadChat(chat: chat) }
+ let chat = chatModel.getChat(id),
+ !NtfManager.shared.navigatingToChat {
+ Task { await loadChat(chat: chat, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
- chatModel.ntfContactRequest = nil
+ await MainActor.run { chatModel.ntfContactRequest = nil }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
@@ -149,9 +158,9 @@ struct SimpleXApp: App {
}
}
- private func updateCallInvitations() {
+ private func updateCallInvitations() async {
do {
- try refreshCallInvitations()
+ try await refreshCallInvitations()
} catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}
diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift
index d4dcc8097f..de67390026 100644
--- a/apps/ios/Shared/Theme/Theme.swift
+++ b/apps/ios/Shared/Theme/Theme.swift
@@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
-func reactOnDarkThemeChanges() {
- if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
+func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
+ if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
@@ -102,7 +102,7 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
- if let parsed = UIImage(base64Encoded: image),
+ if let parsed = imageFromBase64(image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
@@ -122,7 +122,7 @@ extension ThemeWallpaper {
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
- let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
+ let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift
index 55f9a08878..4166619d04 100644
--- a/apps/ios/Shared/Theme/ThemeManager.swift
+++ b/apps/ios/Shared/Theme/ThemeManager.swift
@@ -53,7 +53,7 @@ class ThemeManager {
return perUserTheme
}
let defaultTheme = defaultActiveTheme(appSettingsTheme)
- return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
+ return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
@@ -197,7 +197,7 @@ class ThemeManager {
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
- applyTheme(nonSystemThemeName)
+ applyTheme(currentThemeDefault.get())
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding) -> Bool {
diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift
index 97415018bf..2f76f1f046 100644
--- a/apps/ios/Shared/Views/Call/ActiveCallView.swift
+++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift
@@ -17,8 +17,8 @@ struct ActiveCallView: View {
@ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil
- @State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil
+ @State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
@Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
@@ -27,24 +27,39 @@ struct ActiveCallView: View {
var body: some View {
ZStack(alignment: .topLeading) {
ZStack(alignment: .bottom) {
- if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
+ if let client = client, call.hasVideo {
GeometryReader { g in
let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) {
- CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
- CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
- .cornerRadius(10)
- .frame(width: width, height: width / (localRendererAspectRatio ?? 1))
- .padding([.top, .trailing], 17)
ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001))
+
+ CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
+ .onTapGesture {
+ remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
+ }
+
+ Group {
+ let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
+ if localVideoTrack != nil {
+ CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
+ .onDisappear {
+ localRendererAspectRatio = nil
+ }
+ } else {
+ Rectangle().fill(.black)
+ }
+ }
+ .cornerRadius(10)
+ .frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
+ .padding([.top, .trailing], 17)
}
}
}
- if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
+ if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) {
ActiveCallOverlay(call: call, client: client)
}
}
@@ -54,6 +69,9 @@ struct ActiveCallView: View {
.onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
+ Task {
+ await askRequiredPermissions()
+ }
createWebRTCClient()
dismissAllSheets()
hideKeyboard()
@@ -84,7 +102,7 @@ struct ActiveCallView: View {
private func createWebRTCClient() {
if client == nil && canConnectCall {
- client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
+ client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
Task {
await m.callCommand.setClient(client)
}
@@ -99,7 +117,7 @@ struct ActiveCallView: View {
logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp {
case let .capabilities(capabilities):
- let callType = CallType(media: call.localMedia, capabilities: capabilities)
+ let callType = CallType(media: call.initialCallType, capabilities: capabilities)
Task {
do {
try await apiSendCallInvitation(call.contact, callType)
@@ -110,7 +128,7 @@ struct ActiveCallView: View {
call.callState = .invitationSent
call.localCapabilities = capabilities
}
- if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
+ if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
CallSoundsPlayer.shared.startConnectingCallSound()
@@ -120,7 +138,7 @@ struct ActiveCallView: View {
Task {
do {
try await apiSendCallOffer(call.contact, offer, iceCandidates,
- media: call.localMedia, capabilities: capabilities)
+ media: call.initialCallType, capabilities: capabilities)
} catch {
logger.error("apiSendCallOffer \(responseError(error))")
}
@@ -164,6 +182,9 @@ struct ActiveCallView: View {
}
if state.connectionState == "closed" {
closeCallView(client)
+ if let callUUID = m.activeCall?.callUUID {
+ CallController.shared.endCall(callUUID: callUUID)
+ }
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
@@ -182,10 +203,18 @@ struct ActiveCallView: View {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
+ case let .peerMedia(source, enabled):
+ switch source {
+ case .mic: call.peerMediaSources.mic = enabled
+ case .camera: call.peerMediaSources.camera = enabled
+ case .screenAudio: call.peerMediaSources.screenAudio = enabled
+ case .screenVideo: call.peerMediaSources.screenVideo = enabled
+ case .unknown: ()
+ }
case .ended:
closeCallView(client)
call.callState = .ended
- if let uuid = call.callkitUUID {
+ if let uuid = call.callUUID {
CallController.shared.endCall(callUUID: uuid)
}
case .ok:
@@ -214,16 +243,38 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil
return
}
- if case let .chatItemStatusUpdated(_, msg) = msg,
- msg.chatInfo.id == call.contact.id,
- case .sndCall = msg.chatItem.content,
- case .sndRcvd = msg.chatItem.meta.itemStatus {
+ if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
+ chatItems.contains(where: { ci in
+ ci.chatInfo.id == call.contact.id &&
+ ci.chatItem.content.isSndCall &&
+ ci.chatItem.meta.itemStatus.isSndRcvd
+ }) {
CallSoundsPlayer.shared.startInCallSound()
ChatReceiver.shared.messagesChannel = nil
}
}
}
+ private func askRequiredPermissions() async {
+ let mic = await WebRTCClient.isAuthorized(for: .audio)
+ await MainActor.run {
+ call.localMediaSources.mic = mic
+ }
+ let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
+ var camera = call.initialCallType == .audio || cameraAuthorized
+ if call.initialCallType == .video && !cameraAuthorized {
+ camera = await WebRTCClient.isAuthorized(for: .video)
+ await MainActor.run {
+ if camera, let client {
+ client.setCameraEnabled(true)
+ }
+ }
+ }
+ if !mic || !camera {
+ WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
+ }
+ }
+
private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil {
m.showCallView = false
@@ -239,44 +290,16 @@ struct ActiveCallOverlay: View {
var body: some View {
VStack {
- switch call.localMedia {
- case .video:
+ switch call.hasVideo {
+ case true:
videoCallInfoView(call)
.foregroundColor(.white)
.opacity(0.8)
- .padding()
-
- Spacer()
-
- HStack {
- toggleAudioButton()
- Spacer()
- if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
- toggleSpeakerButton()
- .frame(width: 40, height: 40)
- } else if call.hasMedia {
- AudioDevicePicker()
- .scaleEffect(2)
- .frame(maxWidth: 40, maxHeight: 40)
- } else {
- Color.clear.frame(width: 40, height: 40)
- }
- Spacer()
- endCallButton()
- Spacer()
- if call.videoEnabled {
- flipCameraButton()
- } else {
- Color.clear.frame(width: 40, height: 40)
- }
- Spacer()
- toggleVideoButton()
- }
- .padding(.horizontal, 20)
- .padding(.bottom, 16)
- .frame(maxWidth: .infinity, alignment: .center)
-
- case .audio:
+ .padding(.horizontal)
+ // Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
+ // Otherwise, bigger padding is added by SwiftUI when switching call types
+ .padding(.vertical, 10)
+ case false:
ZStack(alignment: .topLeading) {
Button {
chatModel.activeCallViewIsCollapsed = true
@@ -291,35 +314,32 @@ struct ActiveCallOverlay: View {
}
.foregroundColor(.white)
.opacity(0.8)
- .padding()
+ .padding(.horizontal)
+ .padding(.vertical, 10)
.frame(maxHeight: .infinity)
}
-
- Spacer()
-
- ZStack(alignment: .bottom) {
- toggleAudioButton()
- .frame(maxWidth: .infinity, alignment: .leading)
- endCallButton()
- // Check if the only input is microphone. And in this case show toggle button,
- // If there are more inputs, it probably means something like bluetooth headphones are available
- // and in this case show iOS button for choosing different output.
- // There is no way to get available outputs, only inputs
- if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
- toggleSpeakerButton()
- .frame(maxWidth: .infinity, alignment: .trailing)
- } else if call.hasMedia {
- AudioDevicePicker()
- .scaleEffect(2)
- .frame(maxWidth: 50, maxHeight: 40)
- .frame(maxWidth: .infinity, alignment: .trailing)
- } else {
- Color.clear.frame(width: 50, height: 40)
- }
- }
- .padding(.bottom, 60)
- .padding(.horizontal, 48)
}
+
+ Spacer()
+
+ HStack {
+ toggleMicButton()
+ Spacer()
+ audioDeviceButton()
+ Spacer()
+ endCallButton()
+ Spacer()
+ if call.localMediaSources.camera {
+ flipCameraButton()
+ } else {
+ Color.clear.frame(width: 60, height: 60)
+ }
+ Spacer()
+ toggleCameraButton()
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 16)
+ .frame(maxWidth: 440, alignment: .center)
}
.frame(maxWidth: .infinity)
.onAppear {
@@ -381,36 +401,54 @@ struct ActiveCallOverlay: View {
private func endCallButton() -> some View {
let cc = CallController.shared
- return callButton("phone.down.fill", width: 60, height: 60) {
- if let uuid = call.callkitUUID {
+ return callButton("phone.down.fill", .red, padding: 10) {
+ if let uuid = call.callUUID {
cc.endCall(callUUID: uuid)
} else {
cc.endCall(call: call) {}
}
}
- .foregroundColor(.red)
}
- private func toggleAudioButton() -> some View {
- controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
+ private func toggleMicButton() -> some View {
+ controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) {
Task {
- client.setAudioEnabled(!call.audioEnabled)
- DispatchQueue.main.async {
- call.audioEnabled = !call.audioEnabled
- }
+ if await WebRTCClient.isAuthorized(for: .audio) {
+ client.setAudioEnabled(!call.localMediaSources.mic)
+ } else { WebRTCClient.showUnauthorizedAlert(for: .audio) }
+ }
+ }
+ }
+
+ func audioDeviceButton() -> some View {
+ // Check if the only input is microphone. And in this case show toggle button,
+ // If there are more inputs, it probably means something like bluetooth headphones are available
+ // and in this case show iOS button for choosing different output.
+ // There is no way to get available outputs, only inputs
+ Group {
+ if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
+ toggleSpeakerButton()
+ } else {
+ audioDevicePickerButton()
+ }
+ }
+ .onChange(of: call.localMediaSources.hasVideo) { hasVideo in
+ let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
+ let speakerEnabled = current == .builtInSpeaker
+ let receiverEnabled = current == .builtInReceiver
+ // react automatically only when receiver were selected, otherwise keep an external device selected
+ if !speakerEnabled && hasVideo && receiverEnabled {
+ client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
+ call.speakerEnabled = !speakerEnabled
}
}
}
private func toggleSpeakerButton() -> some View {
- controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
- Task {
- let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
- client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
- DispatchQueue.main.async {
- call.speakerEnabled = !speakerEnabled
- }
- }
+ controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) {
+ let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
+ client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
+ call.speakerEnabled = !speakerEnabled
}
.onAppear {
deviceManager.call = call
@@ -418,53 +456,67 @@ struct ActiveCallOverlay: View {
}
}
- private func toggleVideoButton() -> some View {
- controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
+ private func toggleCameraButton() -> some View {
+ controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) {
Task {
- client.setVideoEnabled(!call.videoEnabled)
- DispatchQueue.main.async {
- call.videoEnabled = !call.videoEnabled
- }
+ if await WebRTCClient.isAuthorized(for: .video) {
+ client.setCameraEnabled(!call.localMediaSources.camera)
+ } else { WebRTCClient.showUnauthorizedAlert(for: .video) }
}
}
+ .disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
}
@ViewBuilder private func flipCameraButton() -> some View {
- controlButton(call, "arrow.triangle.2.circlepath") {
+ controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
Task {
- client.flipCamera()
+ if await WebRTCClient.isAuthorized(for: .video) {
+ client.flipCamera()
+ }
}
}
}
- @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
- if call.hasMedia {
- callButton(imageName, width: 50, height: 38, perform)
- .foregroundColor(.white)
- .opacity(0.85)
- } else {
- Color.clear.frame(width: 50, height: 38)
- }
+ @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
+ callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
}
- private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
+ @ViewBuilder private func audioDevicePickerButton() -> some View {
+ AudioDevicePicker()
+ .opacity(0.8)
+ .scaleEffect(2)
+ .padding(10)
+ .frame(width: 60, height: 60)
+ .background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
+ .clipShape(.circle)
+ }
+
+ private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
Image(systemName: imageName)
.resizable()
.scaledToFit()
- .frame(maxWidth: width, maxHeight: height)
+ .padding(padding)
+ .frame(width: 60, height: 60)
+ .background(background)
}
+ .foregroundColor(whiteColorWithAlpha)
+ .clipShape(.circle)
+ }
+
+ private var whiteColorWithAlpha: Color {
+ get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
}
}
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
- ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
+ ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil)))
.background(.black)
- ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
+ ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil)))
.background(.black)
}
}
diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift
index 64b565e8e6..1f28180e87 100644
--- a/apps/ios/Shared/Views/Call/CallController.swift
+++ b/apps/ios/Shared/Views/Call/CallController.swift
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction")
- if callManager.startOutgoingCall(callUUID: action.callUUID) {
+ if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else {
@@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction")
- if callManager.answerIncomingCall(callUUID: action.callUUID) {
- // WebRTC call should be in connected state to fulfill.
- // Otherwise no audio and mic working on lockscreen
- fulfillOnConnect = action
- } else {
- action.fail()
+ Task {
+ let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
+ logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
+ if !chatIsReady {
+ action.fail()
+ return
+ }
+ if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
+ try? await justRefreshCallInvitations()
+ logger.debug("CallController: updated call invitations chat")
+ }
+ await MainActor.run {
+ logger.debug("CallController.provider will answer on call")
+
+ if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
+ logger.debug("CallController.provider answered on call")
+ // WebRTC call should be in connected state to fulfill.
+ // Otherwise no audio and mic working on lockscreen
+ fulfillOnConnect = action
+ } else {
+ logger.debug("CallController.provider will fail the call")
+ action.fail()
+ }
+ }
}
}
@@ -75,7 +93,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state
fulfillOnConnect?.fail()
fulfillOnConnect = nil
- callManager.endCall(callUUID: action.callUUID) { ok in
+ callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
if ok {
action.fulfill()
} else {
@@ -86,7 +104,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
- if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
+ if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
} else {
action.fail()
@@ -103,8 +121,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
do {
- let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
- if supportsVideo {
+ let hasVideo = ChatModel.shared.activeCall?.hasVideo == true
+ if hasVideo {
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} else {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
@@ -115,7 +133,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
if let preferred = audioSession.preferredInputDevice() {
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
- } else if supportsVideo {
+ } else if hasVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
}
}
@@ -156,6 +174,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
+ private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
+ logger.debug("CallController waiting until chat started")
+ var t: UInt64 = 0
+ repeat {
+ if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
+ return true
+ }
+ _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
+ t += stepMs
+ } while t < timeoutMs
+ return false
+ }
+
@objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
@@ -171,32 +202,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
self.reportExpiredCall(payload: payload, completion)
return
}
- if (!ChatModel.shared.chatInitialized) {
- logger.debug("CallController: initializing chat")
- do {
- try initializeChat(start: true, refreshInvitations: false)
- } catch let error {
- logger.error("CallController: initializing chat error: \(error)")
- self.reportExpiredCall(payload: payload, completion)
- return
- }
- }
- logger.debug("CallController: initialized chat")
- startChatForCall()
- logger.debug("CallController: started chat")
- self.shouldSuspendChat = true
- // There are no invitations in the model, as it was processed by NSE
- _ = try? justRefreshCallInvitations()
- logger.debug("CallController: updated call invitations chat")
- // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload
let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String,
- let invitation = m.callInvitations[contactId] {
- let update = self.cxCallUpdate(invitation: invitation)
- if let uuid = invitation.callkitUUID {
+ let displayName = payload.dictionaryPayload["displayName"] as? String,
+ let callUUID = payload.dictionaryPayload["callUUID"] as? String,
+ let uuid = UUID(uuidString: callUUID),
+ let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
+ let mediaStr = payload.dictionaryPayload["media"] as? String,
+ let media = CallMediaType(rawValue: mediaStr) {
+ let update = self.cxCallUpdate(contactId, displayName, media)
+ let callTs = Date(timeIntervalSince1970: callTsInterval)
+ if callTs.timeIntervalSinceNow >= -180 {
logger.debug("CallController: report pushkit call via CallKit")
- let update = self.cxCallUpdate(invitation: invitation)
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil {
m.callInvitations.removeValue(forKey: contactId)
@@ -205,11 +223,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion()
}
} else {
+ logger.debug("CallController will expire call 1")
self.reportExpiredCall(update: update, completion)
}
} else {
+ logger.debug("CallController will expire call 2")
self.reportExpiredCall(payload: payload, completion)
}
+
+ //DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
+ if (!ChatModel.shared.chatInitialized) {
+ logger.debug("CallController: initializing chat")
+ do {
+ try initializeChat(start: true, refreshInvitations: false)
+ } catch let error {
+ logger.error("CallController: initializing chat error: \(error)")
+ if let call = ChatModel.shared.activeCall {
+ self.endCall(call: call, completed: completion)
+ }
+ return
+ }
+ }
+ logger.debug("CallController: initialized chat")
+ startChatForCall()
+ logger.debug("CallController: started chat")
+ self.shouldSuspendChat = true
}
// This function fulfils the requirement to always report a call when PushKit notification is received,
@@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
- logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
- if CallController.useCallKit(), let uuid = invitation.callkitUUID {
+ logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@@ -261,6 +299,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update
}
+ private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
+ let update = CXCallUpdate()
+ update.remoteHandle = CXHandle(type: .generic, value: contactId)
+ update.hasVideo = media == .video
+ update.localizedCallerName = displayName
+ return update
+ }
+
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() {
@@ -272,14 +318,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected")
- if CallController.useCallKit(), let uuid = call.callkitUUID {
+ if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
}
}
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended")
- if CallController.useCallKit(), let uuid = invitation.callkitUUID {
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil
@@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended")
- if CallController.useCallKit(), let uuid = call.callkitUUID {
+ if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall")
- let uuid = callManager.newOutgoingCall(contact, media)
+ let callUUID = callManager.newOutgoingCall(contact, media)
+ guard let uuid = UUID(uuidString: callUUID) else {
+ return
+ }
if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle)
@@ -307,19 +356,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update)
}
- } else if callManager.startOutgoingCall(callUUID: uuid) {
- if callManager.startOutgoingCall(callUUID: uuid) {
- logger.debug("CallController.startCall: call started")
- } else {
- logger.error("CallController.startCall: no active call")
- }
+ } else if callManager.startOutgoingCall(callUUID: callUUID) {
+ logger.debug("CallController.startCall: call started")
+ } else {
+ logger.error("CallController.startCall: no active call")
}
}
func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call")
- if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
- requestTransaction(with: CXAnswerCallAction(call: callUUID))
+ if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
+ requestTransaction(with: CXAnswerCallAction(call: uuid))
} else {
callManager.answerIncomingCall(invitation: invitation)
}
@@ -328,10 +375,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
- func endCall(callUUID: UUID) {
- logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
+ func endCall(callUUID: String) {
+ let uuid = UUID(uuidString: callUUID)
+ logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() {
- requestTransaction(with: CXEndCallAction(call: callUUID))
+ if let uuid {
+ requestTransaction(with: CXEndCallAction(call: uuid))
+ }
} else {
callManager.endCall(callUUID: callUUID) { ok in
if ok {
diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift
index a6d5ea17c4..a3e1df2301 100644
--- a/apps/ios/Shared/Views/Call/CallManager.swift
+++ b/apps/ios/Shared/Views/Call/CallManager.swift
@@ -10,25 +10,25 @@ import Foundation
import SimpleXChat
class CallManager {
- func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
- let uuid = UUID()
- let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
+ func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
+ let uuid = UUID().uuidString.lowercased()
+ let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media)
call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call
return uuid
}
- func startOutgoingCall(callUUID: UUID) -> Bool {
+ func startOutgoingCall(callUUID: String) -> Bool {
let m = ChatModel.shared
- if let call = m.activeCall, call.callkitUUID == callUUID {
+ if let call = m.activeCall, call.callUUID == callUUID {
m.showCallView = true
- Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
+ Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) }
return true
}
return false
}
- func answerIncomingCall(callUUID: UUID) -> Bool {
+ func answerIncomingCall(callUUID: String) -> Bool {
if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation)
return true
@@ -42,9 +42,9 @@ class CallManager {
let call = Call(
direction: .incoming,
contact: invitation.contact,
- callkitUUID: invitation.callkitUUID,
+ callUUID: invitation.callUUID,
callState: .invitationAccepted,
- localMedia: invitation.callType.media,
+ initialCallType: invitation.callType.media,
sharedKey: invitation.sharedKey
)
call.speakerEnabled = invitation.callType.media == .video
@@ -68,17 +68,17 @@ class CallManager {
}
}
- func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
- if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
+ func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool {
+ if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
let m = ChatModel.shared
- Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
+ Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) }
return true
}
return false
}
- func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
- if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
+ func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
+ if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) }
@@ -126,8 +126,8 @@ class CallManager {
}
}
- private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
- if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
+ private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
+ if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
return invitation
}
return nil
diff --git a/apps/ios/Shared/Views/Call/CallViewRenderers.swift b/apps/ios/Shared/Views/Call/CallViewRenderers.swift
index a3201d9351..e779093a24 100644
--- a/apps/ios/Shared/Views/Call/CallViewRenderers.swift
+++ b/apps/ios/Shared/Views/Call/CallViewRenderers.swift
@@ -10,40 +10,49 @@ import AVKit
struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient
- var activeCall: Binding
+ @ObservedObject var call: Call
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool
+ @Binding var contentMode: UIView.ContentMode
@Binding var pipShown: Bool
- init(client: WebRTCClient, activeCall: Binding, activeCallViewIsCollapsed: Binding, pipShown: Binding) {
- self.client = client
- self.activeCall = activeCall
- self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
- self._pipShown = pipShown
- }
-
func makeUIView(context: Context) -> UIView {
let view = UIView()
- if let call = activeCall.wrappedValue {
- let remoteRenderer = RTCMTLVideoView(frame: view.frame)
- remoteRenderer.videoContentMode = .scaleAspectFill
- client.addRemoteRenderer(call, remoteRenderer)
- addSubviewAndResize(remoteRenderer, into: view)
+ let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
+ remoteCameraRenderer.videoContentMode = contentMode
+ remoteCameraRenderer.tag = 0
- if AVPictureInPictureController.isPictureInPictureSupported() {
- makeViewWithRTCRenderer(call, remoteRenderer, view, context)
- }
+ let screenVideo = call.peerMediaSources.screenVideo
+ let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
+ remoteScreenRenderer.videoContentMode = contentMode
+ remoteScreenRenderer.tag = 1
+ remoteScreenRenderer.alpha = screenVideo ? 1 : 0
+
+ context.coordinator.cameraRenderer = remoteCameraRenderer
+ context.coordinator.screenRenderer = remoteScreenRenderer
+ client.addRemoteCameraRenderer(remoteCameraRenderer)
+ client.addRemoteScreenRenderer(remoteScreenRenderer)
+ if screenVideo {
+ addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
+ } else {
+ addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
+ }
+
+ if AVPictureInPictureController.isPictureInPictureSupported() {
+ makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
}
return view
}
- func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
- let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
- pipRemoteRenderer.videoContentMode = .scaleAspectFill
-
+ func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
+ let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
+ pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill
+
+ let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
+ pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
+
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
- addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController
@@ -55,7 +64,9 @@ struct CallViewRemote: UIViewRepresentable {
context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in
if show {
- client.addRemoteRenderer(call, pipRemoteRenderer)
+ client.addRemoteCameraRenderer(pipRemoteCameraRenderer)
+ client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
+ context.coordinator.relayout()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true
}
@@ -67,13 +78,29 @@ struct CallViewRemote: UIViewRepresentable {
}
context.coordinator.didShowHide = { show in
if show {
- remoteRenderer.isHidden = true
+ remoteCameraRenderer.isHidden = true
+ remoteScreenRenderer.isHidden = true
} else {
- client.removeRemoteRenderer(call, pipRemoteRenderer)
- remoteRenderer.isHidden = false
+ client.removeRemoteCameraRenderer(pipRemoteCameraRenderer)
+ client.removeRemoteScreenRenderer(pipRemoteScreenRenderer)
+ remoteCameraRenderer.isHidden = false
+ remoteScreenRenderer.isHidden = false
}
pipShown = show
}
+ context.coordinator.relayout = {
+ let camera = call.peerMediaSources.camera
+ let screenVideo = call.peerMediaSources.screenVideo
+ pipRemoteCameraRenderer.alpha = camera ? 1 : 0
+ pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
+ if screenVideo {
+ addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
+ } else {
+ addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
+ }
+ (pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
+ (pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
+ }
DispatchQueue.main.async {
enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ {
@@ -88,24 +115,50 @@ struct CallViewRemote: UIViewRepresentable {
}
func makeCoordinator() -> Coordinator {
- Coordinator()
+ Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote")
+ let camera = view.subviews.first(where: { $0.tag == 0 })!
+ let screen = view.subviews.first(where: { $0.tag == 1 })!
+ let screenVideo = call.peerMediaSources.screenVideo
+ if screenVideo && screen.alpha == 0 {
+ screen.alpha = 1
+ addSubviewAndResize(screen, camera, into: view)
+ } else if !screenVideo && screen.alpha == 1 {
+ screen.alpha = 0
+ addSubviewAndResize(camera, screen, into: view)
+ }
+ (view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
+ (view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
+
+ camera.alpha = call.peerMediaSources.camera ? 1 : 0
+ screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
+
DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed)
+ } else if pipShown {
+ context.coordinator.relayout()
}
}
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
+ var cameraRenderer: RTCMTLVideoView?
+ var screenRenderer: RTCMTLVideoView?
+ var client: WebRTCClient
var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in }
-
+ var relayout: () -> Void = {}
+
+ required init(_ client: WebRTCClient) {
+ self.client = client
+ }
+
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true)
}
@@ -127,11 +180,20 @@ struct CallViewRemote: UIViewRepresentable {
}
deinit {
+ // TODO: deinit is not called when changing call type from audio to video and back,
+ // which causes many renderers can be created and added to stream (if enabling/disabling
+ // video while not yet connected in outgoing call)
pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil
pipController?.delegate = nil
pipController = nil
+ if let cameraRenderer {
+ client.removeRemoteCameraRenderer(cameraRenderer)
+ }
+ if let screenRenderer {
+ client.removeRemoteScreenRenderer(screenRenderer)
+ }
}
}
@@ -148,51 +210,109 @@ struct CallViewRemote: UIViewRepresentable {
struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient
- var activeCall: Binding
var localRendererAspectRatio: Binding
@State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool
- init(client: WebRTCClient, activeCall: Binding, localRendererAspectRatio: Binding, pipShown: Binding) {
+ init(client: WebRTCClient, localRendererAspectRatio: Binding, pipShown: Binding) {
self.client = client
- self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
- if let call = activeCall.wrappedValue {
- let localRenderer = RTCEAGLVideoView(frame: .zero)
- client.addLocalRenderer(call, localRenderer)
- client.startCaptureLocalVideo(call)
- addSubviewAndResize(localRenderer, into: view)
- DispatchQueue.main.async {
- pipStateChanged = { shown in
- localRenderer.isHidden = shown
- }
+ let localRenderer = RTCEAGLVideoView(frame: .zero)
+ context.coordinator.renderer = localRenderer
+ client.addLocalRenderer(localRenderer)
+ addSubviewAndResize(localRenderer, nil, into: view)
+ DispatchQueue.main.async {
+ pipStateChanged = { shown in
+ localRenderer.isHidden = shown
}
}
return view
}
+ func makeCoordinator() -> Coordinator {
+ Coordinator(client)
+ }
+
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown)
}
+
+ // MARK: - Coordinator
+ class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
+ var renderer: RTCEAGLVideoView?
+ var client: WebRTCClient
+
+ required init(_ client: WebRTCClient) {
+ self.client = client
+ }
+
+ deinit {
+ if let renderer {
+ client.removeLocalRenderer(renderer)
+ }
+ }
+ }
}
-private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
- containerView.addSubview(view)
- view.translatesAutoresizingMaskIntoConstraints = false
- containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
- options: [],
- metrics: nil,
- views: ["view": view]))
+private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) {
+ if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) {
+ // Nothing to do, elements on their places
+ return
+ }
+ containerView.removeConstraints(containerView.constraints)
+ containerView.subviews.forEach { sub in sub.removeFromSuperview()}
- containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
+ containerView.addSubview(fullscreen)
+ fullscreen.translatesAutoresizingMaskIntoConstraints = false
+ fullscreen.layer.cornerRadius = 0
+ fullscreen.layer.masksToBounds = false
+
+ if let end {
+ containerView.addSubview(end)
+ end.translatesAutoresizingMaskIntoConstraints = false
+ end.layer.cornerRadius = pip ? 8 : 10
+ end.layer.masksToBounds = true
+ }
+
+ let constraintFullscreenV = NSLayoutConstraint.constraints(
+ withVisualFormat: "V:|[fullscreen]|",
options: [],
metrics: nil,
- views: ["view": view]))
+ views: ["fullscreen": fullscreen]
+ )
+ let constraintFullscreenH = NSLayoutConstraint.constraints(
+ withVisualFormat: "H:|[fullscreen]|",
+ options: [],
+ metrics: nil,
+ views: ["fullscreen": fullscreen]
+ )
+
+ containerView.addConstraints(constraintFullscreenV)
+ containerView.addConstraints(constraintFullscreenH)
+
+ if let end {
+ let constraintEndWidth = NSLayoutConstraint(
+ item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
+ )
+ let constraintEndHeight = NSLayoutConstraint(
+ item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
+ )
+ let constraintEndX = NSLayoutConstraint(
+ item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
+ )
+ let constraintEndY = NSLayoutConstraint(
+ item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
+ )
+ containerView.addConstraint(constraintEndWidth)
+ containerView.addConstraint(constraintEndHeight)
+ containerView.addConstraint(constraintEndX)
+ containerView.addConstraint(constraintEndY)
+ }
containerView.layoutIfNeeded()
}
diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift
index 4960281d72..5479a9fada 100644
--- a/apps/ios/Shared/Views/Call/IncomingCallView.swift
+++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift
@@ -38,6 +38,7 @@ struct IncomingCallView: View {
}
HStack {
ProfilePreview(profileOf: invitation.contact, color: .white)
+ .padding(.vertical, 6)
Spacer()
callButton("Reject", "phone.down.fill", .red) {
diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift
index 333dc082d5..ef9135761c 100644
--- a/apps/ios/Shared/Views/Call/WebRTC.swift
+++ b/apps/ios/Shared/Views/Call/WebRTC.swift
@@ -18,49 +18,49 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection
var contact: Contact
- var callkitUUID: UUID?
- var localMedia: CallMediaType
+ var callUUID: String?
+ var initialCallType: CallMediaType
+ @Published var localMediaSources: CallMediaSources
@Published var callState: CallState
@Published var localCapabilities: CallCapabilities?
- @Published var peerMedia: CallMediaType?
+ @Published var peerMediaSources: CallMediaSources = CallMediaSources()
@Published var sharedKey: String?
- @Published var audioEnabled = true
@Published var speakerEnabled = false
- @Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init(
direction: CallDirection,
contact: Contact,
- callkitUUID: UUID?,
+ callUUID: String?,
callState: CallState,
- localMedia: CallMediaType,
+ initialCallType: CallMediaType,
sharedKey: String? = nil
) {
self.direction = direction
self.contact = contact
- self.callkitUUID = callkitUUID
+ self.callUUID = callUUID
self.callState = callState
- self.localMedia = localMedia
+ self.initialCallType = initialCallType
self.sharedKey = sharedKey
- self.videoEnabled = localMedia == .video
+ self.localMediaSources = CallMediaSources(
+ mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
+ camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
}
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
- var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
+ private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey {
get {
switch callState {
case .waitCapabilities: return ""
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
- default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
+ default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
}
}
}
- var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
- var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
+ var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } }
}
enum CallDirection {
@@ -105,18 +105,28 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
var command: WCallCommand?
}
+struct CallMediaSources: Equatable, Codable {
+ var mic: Bool = false
+ var camera: Bool = false
+ var screenAudio: Bool = false
+ var screenVideo: Bool = false
+
+ var hasVideo: Bool { get { camera || screenVideo } }
+}
+
enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities(media: CallMediaType)
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
- case media(media: CallMediaType, enable: Bool)
+ case media(source: CallMediaSource, enable: Bool)
case end
enum CodingKeys: String, CodingKey {
case type
case media
+ case source
case aesKey
case offer
case answer
@@ -167,9 +177,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case let .ice(iceCandidates):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
- case let .media(media, enable):
+ case let .media(source, enable):
try container.encode("media", forKey: .type)
- try container.encode(media, forKey: .media)
+ try container.encode(source, forKey: .media)
try container.encode(enable, forKey: .enable)
case .end:
try container.encode("end", forKey: .type)
@@ -205,9 +215,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
case "media":
- let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
- self = .media(media: media, enable: enable)
+ self = .media(source: source, enable: enable)
case "end":
self = .end
default:
@@ -224,6 +234,7 @@ enum WCallResponse: Equatable, Decodable {
case ice(iceCandidates: String)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
+ case peerMedia(source: CallMediaSource, enabled: Bool)
case ended
case ok
case error(message: String)
@@ -238,6 +249,8 @@ enum WCallResponse: Equatable, Decodable {
case state
case connectionInfo
case message
+ case source
+ case enabled
}
var respType: String {
@@ -249,6 +262,7 @@ enum WCallResponse: Equatable, Decodable {
case .ice: return "ice"
case .connection: return "connection"
case .connected: return "connected"
+ case .peerMedia: return "peerMedia"
case .ended: return "ended"
case .ok: return "ok"
case .error: return "error"
@@ -283,6 +297,10 @@ enum WCallResponse: Equatable, Decodable {
case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: connectionInfo)
+ case "peerMedia":
+ let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
+ let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
+ self = .peerMedia(source: source, enabled: enabled)
case "ended":
self = .ended
case "ok":
@@ -324,6 +342,10 @@ extension WCallResponse: Encodable {
case let .connected(connectionInfo):
try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo)
+ case let .peerMedia(source, enabled):
+ try container.encode("peerMedia", forKey: .type)
+ try container.encode(source, forKey: .source)
+ try container.encode(enabled, forKey: .enabled)
case .ended:
try container.encode("ended", forKey: .type)
case .ok:
@@ -376,7 +398,7 @@ actor WebRTCCommandProcessor {
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
- default: client.activeCall.wrappedValue != nil
+ default: client.activeCall != nil
}
}
}
diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift
index 0b4917c103..db7910836e 100644
--- a/apps/ios/Shared/Views/Call/WebRTCClient.swift
+++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift
@@ -23,15 +23,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
struct Call {
var connection: RTCPeerConnection
var iceCandidates: IceCandidates
- var localMedia: CallMediaType
var localCamera: RTCVideoCapturer?
- var localVideoSource: RTCVideoSource?
- var localStream: RTCVideoTrack?
- var remoteStream: RTCVideoTrack?
- var device: AVCaptureDevice.Position = .front
+ var localAudioTrack: RTCAudioTrack?
+ var localVideoTrack: RTCVideoTrack?
+ var remoteAudioTrack: RTCAudioTrack?
+ var remoteVideoTrack: RTCVideoTrack?
+ var remoteScreenAudioTrack: RTCAudioTrack?
+ var remoteScreenVideoTrack: RTCVideoTrack?
+ var device: AVCaptureDevice.Position
var aesKey: String?
var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor?
+ var peerHasOldVersion: Bool
+ }
+
+ struct NotConnectedCall {
+ var audioTrack: RTCAudioTrack?
+ var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
+ var device: AVCaptureDevice.Position = .front
}
actor IceCandidates {
@@ -51,17 +60,20 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
private var sendCallResponse: (WVAPIMessage) async -> Void
- var activeCall: Binding
+ var activeCall: Call?
+ var notConnectedCall: NotConnectedCall?
private var localRendererAspectRatio: Binding
+ var cameraRenderers: [RTCVideoRenderer] = []
+ var screenRenderers: [RTCVideoRenderer] = []
+
@available(*, unavailable)
override init() {
fatalError("Unimplemented")
}
- required init(_ activeCall: Binding, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding) {
+ required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding) {
self.sendCallResponse = sendCallResponse
- self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
rtcAudioSession.useManualAudio = CallController.useCallKit()
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
@@ -78,39 +90,45 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self
- createAudioSender(connection)
- var localStream: RTCVideoTrack? = nil
- var remoteStream: RTCVideoTrack? = nil
+ let device = notConnectedCall?.device ?? .front
var localCamera: RTCVideoCapturer? = nil
- var localVideoSource: RTCVideoSource? = nil
- if mediaType == .video {
- (localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
+ var localAudioTrack: RTCAudioTrack? = nil
+ var localVideoTrack: RTCVideoTrack? = nil
+ if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack {
+ (localCamera, localVideoTrack) = localCameraAndTrack
+ } else if notConnectedCall == nil && mediaType == .video {
+ (localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
}
+ if let audioTrack = notConnectedCall?.audioTrack {
+ localAudioTrack = audioTrack
+ } else if notConnectedCall == nil {
+ localAudioTrack = createAudioTrack()
+ }
+ notConnectedCall?.localCameraAndTrack = nil
+ notConnectedCall?.audioTrack = nil
+
var frameEncryptor: RTCFrameEncryptor? = nil
var frameDecryptor: RTCFrameDecryptor? = nil
if aesKey != nil {
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
encryptor.delegate = self
frameEncryptor = encryptor
- connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
decryptor.delegate = self
frameDecryptor = decryptor
- // Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
- // connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
}
return Call(
connection: connection,
iceCandidates: IceCandidates(),
- localMedia: mediaType,
localCamera: localCamera,
- localVideoSource: localVideoSource,
- localStream: localStream,
- remoteStream: remoteStream,
+ localAudioTrack: localAudioTrack,
+ localVideoTrack: localVideoTrack,
+ device: device,
aesKey: aesKey,
frameEncryptor: frameEncryptor,
- frameDecryptor: frameDecryptor
+ frameDecryptor: frameDecryptor,
+ peerHasOldVersion: false
)
}
@@ -151,18 +169,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func sendCallCommand(command: WCallCommand) async {
var resp: WCallResponse? = nil
- let pc = activeCall.wrappedValue?.connection
+ let pc = activeCall?.connection
switch command {
- case .capabilities:
+ case let .capabilities(media): // outgoing
+ let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
+ ? createVideoTrackAndStartCapture(.front)
+ : nil
+ notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
- case let .start(media: media, aesKey, iceServers, relay):
+ case let .start(media: media, aesKey, iceServers, relay): // incoming
logger.debug("starting incoming call - create webrtc session")
- if activeCall.wrappedValue != nil { endCall() }
+ if activeCall != nil { endCall() }
let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
- activeCall.wrappedValue = call
+ activeCall = call
+ setupLocalTracks(true, call)
let (offer, error) = await call.connection.offer()
if let offer = offer {
+ setupEncryptionForLocalTracks(call)
resp = .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
@@ -172,18 +196,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else {
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
}
- case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
- if activeCall.wrappedValue != nil {
+ case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing
+ if activeCall != nil {
resp = .error(message: "accept: call already started")
} else if !WebRTCClient.enableEncryption && aesKey != nil {
resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
- activeCall.wrappedValue = call
+ activeCall = call
let pc = call.connection
if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
+ setupLocalTracks(false, call)
+ setupEncryptionForLocalTracks(call)
+ pc.transceivers.forEach { transceiver in
+ transceiver.setDirection(.sendRecv, error: nil)
+ }
+ await adaptToOldVersion(pc.transceivers.count <= 2)
let (answer, error) = await pc.answer()
if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates)
@@ -200,7 +230,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
}
- case let .answer(answer, iceCandidates):
+ case let .answer(answer, iceCandidates): // incoming
if pc == nil {
resp = .error(message: "answer: call not started")
} else if pc?.localDescription == nil {
@@ -212,6 +242,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
let type = answer.type, let sdp = answer.sdp,
let pc = pc {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
+ var currentDirection: RTCRtpTransceiverDirection = .sendOnly
+ pc.transceivers[2].currentDirection(¤tDirection)
+ await adaptToOldVersion(currentDirection == .sendOnly)
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
} else {
@@ -226,13 +259,11 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else {
resp = .error(message: "ice: call not started")
}
- case let .media(media, enable):
- if activeCall.wrappedValue == nil {
+ case let .media(source, enable):
+ if activeCall == nil {
resp = .error(message: "media: call not started")
- } else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
- resp = .error(message: "media: no video")
} else {
- enableMedia(media, enable)
+ await enableMedia(source, enable)
resp = .ok
}
case .end:
@@ -247,7 +278,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
- let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
+ let candidates = await activeCall?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates
}
@@ -255,7 +286,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func waitForMoreIceCandidates() {
Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
- let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
+ let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates)
@@ -272,25 +303,202 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
)
}
- func enableMedia(_ media: CallMediaType, _ enable: Bool) {
- logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
- media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
+ func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) {
+ // logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)")
+ Task {
+ var lastBytesReceived: Int64 = 0
+ // muted initially
+ var mutedSeconds = 4
+ while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
+ let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
+ let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
+ if let stat {
+ //logger.debug("Stat \(stat.debugDescription)")
+ let bytes = stat.values["bytesReceived"] as! Int64
+ if bytes <= lastBytesReceived {
+ mutedSeconds += 1
+ if mutedSeconds == 3 {
+ await MainActor.run {
+ self.onMediaMuteUnmute(transceiver.mid, true)
+ }
+ }
+ } else {
+ if mutedSeconds >= 3 {
+ await MainActor.run {
+ self.onMediaMuteUnmute(transceiver.mid, false)
+ }
+ }
+ lastBytesReceived = bytes
+ mutedSeconds = 0
+ }
+ }
+ try? await Task.sleep(nanoseconds: 1000_000000)
+ }
+ }
}
- func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
- activeCall.localStream?.add(renderer)
+ @MainActor
+ func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) {
+ guard let activeCall = ChatModel.shared.activeCall else { return }
+ let source = mediaSourceFromTransceiverMid(transceiverMid)
+ logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
+ if source == .mic && activeCall.peerMediaSources.mic == mute {
+ activeCall.peerMediaSources.mic = !mute
+ } else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
+ activeCall.peerMediaSources.camera = !mute
+ } else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
+ activeCall.peerMediaSources.screenAudio = !mute
+ } else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
+ activeCall.peerMediaSources.screenVideo = !mute
+ }
+ }
+
+ @MainActor
+ func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
+ logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
+ source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
+ }
+
+ @MainActor
+ func adaptToOldVersion(_ peerHasOldVersion: Bool) {
+ activeCall?.peerHasOldVersion = peerHasOldVersion
+ if peerHasOldVersion {
+ logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
+ onMediaMuteUnmute("0", false)
+ if activeCall?.remoteVideoTrack != nil {
+ onMediaMuteUnmute("1", false)
+ }
+ if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
+ logger.debug("Stopping video track for the old version")
+ activeCall?.connection.senders[1].track = nil
+ ChatModel.shared.activeCall?.localMediaSources.camera = false
+ (activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
+ activeCall?.localCamera = nil
+ activeCall?.localVideoTrack = nil
+ }
+ }
+ }
+
+ func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
+ if let activeCall {
+ if let track = activeCall.localVideoTrack {
+ track.add(renderer)
+ }
+ } else if let notConnectedCall {
+ if let track = notConnectedCall.localCameraAndTrack?.1 {
+ track.add(renderer)
+ }
+ }
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
renderer.delegate = self
}
+ func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
+ if let activeCall {
+ if let track = activeCall.localVideoTrack {
+ track.remove(renderer)
+ }
+ } else if let notConnectedCall {
+ if let track = notConnectedCall.localCameraAndTrack?.1 {
+ track.remove(renderer)
+ }
+ }
+ renderer.delegate = nil
+ }
+
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
guard size.height > 0 else { return }
localRendererAspectRatio.wrappedValue = size.width / size.height
}
+ func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
+ let pc = call.connection
+ let transceivers = call.connection.transceivers
+ let audioTrack = call.localAudioTrack
+ let videoTrack = call.localVideoTrack
+
+ if incomingCall {
+ let micCameraInit = RTCRtpTransceiverInit()
+ // streamIds required for old versions which adds tracks from stream, not from track property
+ micCameraInit.streamIds = ["micCamera"]
+
+ let screenAudioVideoInit = RTCRtpTransceiverInit()
+ screenAudioVideoInit.streamIds = ["screenAudioVideo"]
+
+ // incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
+ // mid = 0, mic
+ if let audioTrack {
+ pc.addTransceiver(with: audioTrack, init: micCameraInit)
+ } else {
+ pc.addTransceiver(of: .audio, init: micCameraInit)
+ }
+ // mid = 1, camera
+ if let videoTrack {
+ pc.addTransceiver(with: videoTrack, init: micCameraInit)
+ } else {
+ pc.addTransceiver(of: .video, init: micCameraInit)
+ }
+ // mid = 2, screenAudio
+ pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
+ // mid = 3, screenVideo
+ pc.addTransceiver(of: .video, init: screenAudioVideoInit)
+ } else {
+ // new version
+ if transceivers.count > 2 {
+ // Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
+ transceivers
+ .first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
+ .sender.track = audioTrack
+ transceivers
+ .first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
+ .sender.track = videoTrack
+ } else {
+ // old version, only two transceivers
+ if let audioTrack {
+ pc.add(audioTrack, streamIds: ["micCamera"])
+ } else {
+ // it's important to have any track in order to be able to turn it on again (currently it's off)
+ let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
+ sender?.track = nil
+ }
+ if let videoTrack {
+ pc.add(videoTrack, streamIds: ["micCamera"])
+ } else {
+ // it's important to have any track in order to be able to turn it on again (currently it's off)
+ let localVideoSource = WebRTCClient.factory.videoSource()
+ let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
+ let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
+ sender?.track = nil
+ }
+ }
+ }
+ }
+
+ func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
+ switch mid {
+ case "0":
+ return .mic
+ case "1":
+ return .camera
+ case "2":
+ return .screenAudio
+ case "3":
+ return .screenVideo
+ default:
+ return .unknown
+ }
+ }
+
+ // Should be called after local description set
+ func setupEncryptionForLocalTracks(_ call: Call) {
+ if let encryptor = call.frameEncryptor {
+ call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
+ }
+ }
+
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
guard encrypted.count > 0 else { return nil }
- if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
+ if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
let isKeyFrame = encrypted[0] & 1 == 0
@@ -304,7 +512,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
guard unencrypted.count > 0 else { return nil }
- if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
+ if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0
@@ -327,18 +535,42 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
- func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
- activeCall.remoteStream?.add(renderer)
+ func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteVideoTrack != nil {
+ activeCall?.remoteVideoTrack?.add(renderer)
+ } else {
+ cameraRenderers.append(renderer)
+ }
}
- func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
- activeCall.remoteStream?.remove(renderer)
+ func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteVideoTrack != nil {
+ activeCall?.remoteVideoTrack?.remove(renderer)
+ } else {
+ cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
+ }
}
- func startCaptureLocalVideo(_ activeCall: Call) {
+ func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteScreenVideoTrack != nil {
+ activeCall?.remoteScreenVideoTrack?.add(renderer)
+ } else {
+ screenRenderers.append(renderer)
+ }
+ }
+
+ func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
+ if activeCall?.remoteScreenVideoTrack != nil {
+ activeCall?.remoteScreenVideoTrack?.remove(renderer)
+ } else {
+ screenRenderers.removeAll(where: { $0.isEqual(renderer) })
+ }
+ }
+
+ func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
#if targetEnvironment(simulator)
guard
- let capturer = activeCall.localCamera as? RTCFileVideoCapturer
+ let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer
else {
logger.error("Unable to work with a file capturer")
return
@@ -348,10 +580,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
#else
guard
- let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
- let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
+ let capturer = capturer as? RTCCameraVideoCapturer,
+ let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device })
else {
- logger.error("Unable to find a camera")
+ logger.error("Unable to find a camera or local track")
return
}
@@ -377,19 +609,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif
}
- private func createAudioSender(_ connection: RTCPeerConnection) {
- let streamId = "stream"
- let audioTrack = createAudioTrack()
- connection.add(audioTrack, streamIds: [streamId])
- }
-
- private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
- let streamId = "stream"
- let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
- connection.add(localVideoTrack, streamIds: [streamId])
- return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
- }
-
private func createAudioTrack() -> RTCAudioTrack {
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
@@ -397,7 +616,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
return audioTrack
}
- private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
+ private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) {
let localVideoSource = WebRTCClient.factory.videoSource()
#if targetEnvironment(simulator)
@@ -407,19 +626,30 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
- return (localVideoTrack, localCamera, localVideoSource)
+ startCaptureLocalVideo(device, localCamera)
+ return (localCamera, localVideoTrack)
}
func endCall() {
- guard let call = activeCall.wrappedValue else { return }
+ if #available(iOS 16.0, *) {
+ _endCall()
+ } else {
+ // Fixes `connection.close()` getting locked up in iOS15
+ DispatchQueue.global(qos: .utility).async { self._endCall() }
+ }
+ }
+
+ private func _endCall() {
+ (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
+ guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call")
- activeCall.wrappedValue = nil
- (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
call.connection.close()
call.connection.delegate = nil
call.frameEncryptor?.delegate = nil
call.frameDecryptor?.delegate = nil
+ (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
audioSessionToDefaults()
+ activeCall = nil
}
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
@@ -428,7 +658,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
await action()
- } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
+ } while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete
}
}
@@ -489,11 +719,40 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
logger.debug("Connection should negotiate")
}
+ func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
+ if let track = transceiver.receiver.track {
+ DispatchQueue.main.async {
+ // Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
+// if let decryptor = self.activeCall?.frameDecryptor {
+// transceiver.receiver.setRtcFrameDecryptor(decryptor)
+// }
+ let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
+ switch source {
+ case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
+ case .camera:
+ self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
+ self.cameraRenderers.forEach({ renderer in
+ self.activeCall?.remoteVideoTrack?.add(renderer)
+ })
+ self.cameraRenderers.removeAll()
+ case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
+ case .screenVideo:
+ self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
+ self.screenRenderers.forEach({ renderer in
+ self.activeCall?.remoteScreenVideoTrack?.add(renderer)
+ })
+ self.screenRenderers.removeAll()
+ case .unknown: ()
+ }
+ }
+ self.setupMuteUnmuteListener(transceiver, track)
+ }
+ }
+
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
- guard let call = activeCall.wrappedValue,
- let connectionStateString = newState.toString(),
+ guard let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString()
@@ -514,18 +773,14 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
switch newState {
case .checking:
- if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
+ if let frameDecryptor = activeCall?.frameDecryptor {
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
}
- let enableSpeaker: Bool
- switch call.localMedia {
- case .video: enableSpeaker = true
- default: enableSpeaker = false
- }
+ let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall()
- default: do {}
+ default: ()
}
}
}
@@ -537,7 +792,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task {
- await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
+ await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil))
}
}
@@ -592,11 +847,42 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
}
extension WebRTCClient {
- func setAudioEnabled(_ enabled: Bool) {
- setTrackEnabled(RTCAudioTrack.self, enabled)
+ static func isAuthorized(for type: AVMediaType) async -> Bool {
+ let status = AVCaptureDevice.authorizationStatus(for: type)
+ var isAuthorized = status == .authorized
+ if status == .notDetermined {
+ isAuthorized = await AVCaptureDevice.requestAccess(for: type)
+ }
+ return isAuthorized
}
- func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
+ static func showUnauthorizedAlert(for type: AVMediaType) {
+ if type == .audio {
+ AlertManager.shared.showAlert(Alert(
+ title: Text("No permission to record speech"),
+ message: Text("To record speech please grant permission to use Microphone."),
+ primaryButton: .default(Text("Open Settings")) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
+ }
+ },
+ secondaryButton: .cancel()
+ ))
+ } else if type == .video {
+ AlertManager.shared.showAlert(Alert(
+ title: Text("No permission to record video"),
+ message: Text("To record video please grant permission to use Camera."),
+ primaryButton: .default(Text("Open Settings")) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
+ }
+ },
+ secondaryButton: .cancel()
+ ))
+ }
+ }
+
+ func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
audioQueue.async { [weak self] in
guard let self = self else { return }
@@ -609,7 +895,7 @@ extension WebRTCClient {
if enabled {
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
- if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
+ if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
try self.rtcAudioSession.setPreferredInput(preferred)
} else {
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
@@ -619,7 +905,7 @@ extension WebRTCClient {
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.overrideOutputAudioPort(.none)
}
- if hasExternalAudioDevice {
+ if hasExternalAudioDevice && !skipExternalDevice {
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
}
try self.rtcAudioSession.setActive(true)
@@ -650,25 +936,59 @@ extension WebRTCClient {
}
}
- func setVideoEnabled(_ enabled: Bool) {
- setTrackEnabled(RTCVideoTrack.self, enabled)
+ @MainActor
+ func setAudioEnabled(_ enabled: Bool) {
+ if activeCall != nil {
+ activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
+ activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
+ } else if notConnectedCall != nil {
+ notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
+ }
+ ChatModel.shared.activeCall?.localMediaSources.mic = enabled
+ }
+
+ @MainActor
+ func setCameraEnabled(_ enabled: Bool) {
+ if let call = activeCall {
+ if enabled {
+ if call.localVideoTrack == nil {
+ let device = activeCall?.device ?? notConnectedCall?.device ?? .front
+ let (camera, track) = createVideoTrackAndStartCapture(device)
+ activeCall?.localCamera = camera
+ activeCall?.localVideoTrack = track
+ }
+ } else {
+ (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
+ activeCall?.localCamera = nil
+ activeCall?.localVideoTrack = nil
+ }
+ call.connection.transceivers
+ .first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
+ .sender.track = activeCall?.localVideoTrack
+ ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
+ } else if let call = notConnectedCall {
+ if enabled {
+ let device = activeCall?.device ?? notConnectedCall?.device ?? .front
+ notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
+ } else {
+ (call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
+ notConnectedCall?.localCameraAndTrack = nil
+ }
+ ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
+ }
}
func flipCamera() {
- switch activeCall.wrappedValue?.device {
- case .front: activeCall.wrappedValue?.device = .back
- case .back: activeCall.wrappedValue?.device = .front
- default: ()
+ let device = activeCall?.device ?? notConnectedCall?.device
+ if activeCall != nil {
+ activeCall?.device = device == .front ? .back : .front
+ } else {
+ notConnectedCall?.device = device == .front ? .back : .front
}
- if let call = activeCall.wrappedValue {
- startCaptureLocalVideo(call)
- }
- }
-
- private func setTrackEnabled(_ type: T.Type, _ enabled: Bool) {
- activeCall.wrappedValue?.connection.transceivers
- .compactMap { $0.sender.track as? T }
- .forEach { $0.isEnabled = enabled }
+ startCaptureLocalVideo(
+ activeCall?.device ?? notConnectedCall?.device,
+ (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index 8c9112a858..62a41c504a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -45,7 +45,7 @@ struct ChatInfoToolbar: View {
}
private var contactVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index ff46b9619c..9b6b9b73e8 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -96,6 +96,8 @@ struct ChatInfoView: View {
@ObservedObject var chat: Chat
@State var contact: Contact
@State var localAlias: String
+ @State var featuresAllowed: ContactFeaturesAllowed
+ @State var currentFeaturesAllowed: ContactFeaturesAllowed
var onSearch: () -> Void
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@@ -154,8 +156,8 @@ struct ChatInfoView: View {
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
- AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
- VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
@@ -312,7 +314,15 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
- case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
+ case .syncConnectionForceAlert:
+ return syncConnectionForceAlert({
+ Task {
+ if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
+ connectionStats = stats
+ dismiss()
+ }
+ }
+ })
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
@@ -327,6 +337,16 @@ struct ChatInfoView: View {
$0.content
}
}
+ .onDisappear {
+ if currentFeaturesAllowed != featuresAllowed {
+ showAlert(
+ title: NSLocalizedString("Save preferences?", comment: "alert title"),
+ buttonTitle: NSLocalizedString("Save and notify contact", comment: "alert button"),
+ buttonAction: { savePreferences() },
+ cancelButton: true
+ )
+ }
+ }
}
private func contactInfoHeader() -> some View {
@@ -339,7 +359,7 @@ struct ChatInfoView: View {
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(contact.profile.displayName)
.font(.largeTitle)
)
@@ -447,8 +467,9 @@ struct ChatInfoView: View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
- featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
- currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
+ featuresAllowed: $featuresAllowed,
+ currentFeaturesAllowed: $currentFeaturesAllowed,
+ savePreferences: savePreferences
)
.navigationBarTitle("Contact preferences")
.modifier(ThemedBackground(grouped: true))
@@ -480,7 +501,12 @@ struct ChatInfoView: View {
private func synchronizeConnectionButton() -> some View {
Button {
- syncContactConnection(force: false)
+ Task {
+ if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
+ connectionStats = stats
+ dismiss()
+ }
+ }
} label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange)
@@ -599,29 +625,50 @@ struct ChatInfoView: View {
}
}
- private func syncContactConnection(force: Bool) {
+ private func savePreferences() {
Task {
do {
- let stats = try apiSyncContactRatchet(contact.apiId, force)
- connectionStats = stats
- await MainActor.run {
- chatModel.updateContactConnectionStats(contact, stats)
- dismiss()
- }
- } catch let error {
- logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
- let a = getErrorAlert(error, "Error synchronizing connection")
- await MainActor.run {
- alert = .error(title: a.title, error: a.message)
+ let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
+ if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
+ await MainActor.run {
+ contact = toContact
+ chatModel.updateContact(toContact)
+ currentFeaturesAllowed = featuresAllowed
+ }
}
+ } catch {
+ logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
}
}
}
}
+func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
+ do {
+ let stats = try apiSyncContactRatchet(contact.apiId, force)
+ await MainActor.run {
+ ChatModel.shared.updateContactConnectionStats(contact, stats)
+ }
+ return stats
+ } catch let error {
+ logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
+ let a = getErrorAlert(error, "Error synchronizing connection")
+ await MainActor.run {
+ showAlert(
+ SomeAlert(
+ alert: mkAlert(title: a.title, message: a.message),
+ id: "syncContactConnection error"
+ )
+ )
+ }
+ return nil
+ }
+}
+
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -629,6 +676,7 @@ struct AudioCallButton: View {
CallButton(
chat: chat,
contact: contact,
+ connectionStats: $connectionStats,
image: "phone.fill",
title: "call",
mediaType: .audio,
@@ -641,6 +689,7 @@ struct AudioCallButton: View {
struct VideoButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -648,6 +697,7 @@ struct VideoButton: View {
CallButton(
chat: chat,
contact: contact,
+ connectionStats: $connectionStats,
image: "video.fill",
title: "video",
mediaType: .video,
@@ -660,6 +710,7 @@ struct VideoButton: View {
private struct CallButton: View {
var chat: Chat
var contact: Contact
+ @Binding var connectionStats: ConnectionStats?
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
@@ -671,7 +722,42 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
- CallController.shared.startCall(contact, mediaType)
+ if let connStats = connectionStats {
+ if connStats.ratchetSyncState == .ok {
+ if CallController.useCallKit() {
+ CallController.shared.startCall(contact, mediaType)
+ } else {
+ // When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
+ dismissAllSheets(animated: true) {
+ CallController.shared.startCall(contact, mediaType)
+ }
+ }
+ } else if connStats.ratchetSyncAllowed {
+ showAlert(SomeAlert(
+ alert: Alert(
+ title: Text("Fix connection?"),
+ message: Text("Connection requires encryption renegotiation."),
+ primaryButton: .default(Text("Fix")) {
+ Task {
+ if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
+ connectionStats = stats
+ }
+ }
+ },
+ secondaryButton: .cancel()
+ ),
+ id: "can't call contact, fix connection"
+ ))
+ } else {
+ showAlert(SomeAlert(
+ alert: mkAlert(
+ title: "Can't call contact",
+ message: "Encryption renegotiation in progress."
+ ),
+ id: "can't call contact, encryption renegotiation in progress"
+ ))
+ }
+ }
} else if contact.nextSendGrpInv {
showAlert(SomeAlert(
alert: mkAlert(
@@ -935,7 +1021,7 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
)
}
-func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
+func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
let (rcvMsgInfo, qInfo) = info
var msgInfo: String
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
@@ -1166,6 +1252,8 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
localAlias: "",
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
onSearch: {}
)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
index 27d8d9c2de..02be8af73b 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
@@ -11,11 +11,11 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
- @Binding var revealed: Bool
var feature: Feature
var icon: String? = nil
var iconColor: Color
@@ -106,6 +106,9 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
- CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
+ CIChatFeatureView(
+ chat: Chat.sampleData,
+ chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
+ ).environment(\.revealed, true)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
index 752f599c8d..2c9c261536 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift
@@ -47,7 +47,7 @@ struct CIFeaturePreferenceView: View {
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
- + Text(" ")
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index fcb330c321..f5ab7f3a4b 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -285,17 +285,18 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
+ 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))
+ ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
index ef0fec5dfe..107208a033 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
@@ -44,16 +45,16 @@ struct CIGroupInvitationView: View {
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
}
} else {
(
groupInvitationText()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
}
@@ -69,7 +70,7 @@ struct CIGroupInvitationView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, theme))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index 3966d7e258..b06c6df48c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -165,9 +165,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
+ .invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
- .foregroundColor(.white)
.padding(padding)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index 3c864ab172..692e6bb8a6 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -16,7 +16,7 @@ struct CILinkView: View {
var body: some View {
VStack(alignment: .center, spacing: 6) {
- if let uiImage = UIImage(base64Encoded: linkPreview.image) {
+ if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
index 463695ddb7..d24c737907 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
@@ -23,7 +23,7 @@ struct CIMemberCreatedContactView: View {
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
- m.chatId = "@\(contactId)"
+ ItemsModel.shared.loadOpenChat("@\(contactId)")
}
}
} else {
@@ -45,7 +45,7 @@ struct CIMemberCreatedContactView: View {
+ Text(openText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
- + Text(" ")
+ + Text(verbatim: " ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index 66b810cf2f..e58ad0f74e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -12,11 +12,13 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true
var showEdited = true
+ var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@@ -24,93 +26,143 @@ struct CIMetaView: View {
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
- let meta = chatItem.meta
- let ttl = chat.chatInfo.timedMessagesTTL
- let encrypted = chatItem.encryptedFile
- switch meta.itemStatus {
- case let .sndSent(sndProgress):
- switch sndProgress {
- case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
+ ZStack {
+ ciMetaText(
+ chatItem.meta,
+ chatTTL: chat.chatInfo.timedMessagesTTL,
+ encrypted: chatItem.encryptedFile,
+ color: metaColor,
+ paleColor: paleMetaColor,
+ colorMode: invertedMaterial
+ ? .invertedMaterial
+ : .normal,
+ showStatus: showStatus,
+ showEdited: showEdited,
+ showViaProxy: showSentViaProxy,
+ showTimesamp: showTimestamp
+ ).invertedForegroundStyle(enabled: invertedMaterial)
+ if invertedMaterial {
+ ciMetaText(
+ chatItem.meta,
+ chatTTL: chat.chatInfo.timedMessagesTTL,
+ encrypted: chatItem.encryptedFile,
+ colorMode: .normal,
+ onlyOverrides: true,
+ showStatus: showStatus,
+ showEdited: showEdited,
+ showViaProxy: showSentViaProxy,
+ showTimesamp: showTimestamp
+ )
}
- case let .sndRcvd(_, sndProgress):
- switch sndProgress {
- case .complete:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- }
- case .partial:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
- }
- }
- default:
- ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
}
}
-enum SentCheckmark {
- case sent
- case rcvd1
- case rcvd2
+enum MetaColorMode {
+ // Renders provided colours
+ case normal
+ // Fully transparent meta - used for reserving space
+ case transparent
+ // Renders white on dark backgrounds and black on light ones
+ case invertedMaterial
+
+ func resolve(_ c: Color?) -> Color? {
+ switch self {
+ case .normal: c
+ case .transparent: .clear
+ case .invertedMaterial: nil
+ }
+ }
+
+ func statusSpacer(_ sent: Bool) -> Text {
+ switch self {
+ case .normal, .transparent:
+ Text(
+ sent
+ ? Image("checkmark.wide")
+ : Image(systemName: "circlebadge.fill")
+ ).foregroundColor(.clear)
+ case .invertedMaterial: textSpace.kerning(13)
+ }
+ }
}
func ciMetaText(
_ meta: CIMeta,
chatTTL: Int?,
encrypted: Bool?,
- color: Color = .clear,
+ color: Color = .clear, // we use this function to reserve space without rendering meta
+ paleColor: Color? = nil,
primaryColor: Color = .accentColor,
- transparent: Bool = false,
- sent: SentCheckmark? = nil,
+ colorMode: MetaColorMode = .normal,
+ onlyOverrides: Bool = false, // only render colors that differ from base
showStatus: Bool = true,
showEdited: Bool = true,
- showViaProxy: Bool
+ showViaProxy: Bool,
+ showTimesamp: Bool
) -> Text {
var r = Text("")
+ var space: Text? = nil
+ let appendSpace = {
+ if let sp = space {
+ r = r + sp
+ space = nil
+ }
+ }
+ let resolved = colorMode.resolve(color)
if showEdited, meta.itemEdited {
- r = r + statusIconText("pencil", color)
+ r = r + statusIconText("pencil", resolved)
}
if meta.disappearing {
- r = r + statusIconText("timer", color).font(.caption2)
+ r = r + statusIconText("timer", resolved).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
- r = r + Text(shortTimeText(ttl)).foregroundColor(color)
+ r = r + colored(Text(shortTimeText(ttl)), resolved)
}
- r = r + Text(" ")
+ space = textSpace
}
if showViaProxy, meta.sentViaProxy == true {
- r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
+ appendSpace()
+ r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
}
if showStatus {
- if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
- let t = Text(Image(systemName: icon)).font(.caption2)
- let gap = Text(" ").kerning(-1.25)
- let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
- switch sent {
- case nil: r = r + t1
- case .sent: r = r + t1 + gap
- case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
- case .rcvd2: r = r + gap + t1
+ appendSpace()
+ if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
+ let metaColor = if onlyOverrides && statusColor == color {
+ Color.clear
+ } else {
+ colorMode.resolve(statusColor)
}
- r = r + Text(" ")
+ r = r + colored(Text(image), metaColor)
} else if !meta.disappearing {
- r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
+ r = r + colorMode.statusSpacer(meta.itemStatus.sent)
}
+ space = textSpace
}
if let enc = encrypted {
- r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
+ appendSpace()
+ r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
+ space = textSpace
+ }
+ if showTimesamp {
+ appendSpace()
+ r = r + colored(meta.timestampText, resolved)
}
- r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption)
}
-private func statusIconText(_ icon: String, _ color: Color) -> Text {
- Text(Image(systemName: icon)).foregroundColor(color)
+private func statusIconText(_ icon: String, _ color: Color?) -> Text {
+ colored(Text(Image(systemName: icon)), color)
+}
+
+// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
+private func colored(_ t: Text, _ color: Color?) -> Text {
+ if let color {
+ t.foregroundColor(color)
+ } else {
+ t
+ }
}
struct CIMetaView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index 1f2e16448d..4603a026cd 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
+ @Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@@ -47,7 +48,7 @@ struct CIRcvDecryptionError: View {
if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
- let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
+ let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s)
}
@@ -68,37 +69,40 @@ struct CIRcvDecryptionError: View {
}
@ViewBuilder private func viewBody() -> some View {
- if case let .direct(contact) = chat.chatInfo,
- let contactStats = contact.activeConn?.connectionStats {
- if contactStats.ratchetSyncAllowed {
- decryptionErrorItemFixButton(syncSupported: true) {
- alert = .syncAllowedAlert { syncContactConnection(contact) }
+ Group {
+ if case let .direct(contact) = chat.chatInfo,
+ let contactStats = contact.activeConn?.connectionStats {
+ if contactStats.ratchetSyncAllowed {
+ decryptionErrorItemFixButton(syncSupported: true) {
+ alert = .syncAllowedAlert { syncContactConnection(contact) }
+ }
+ } else if !contactStats.ratchetSyncSupported {
+ decryptionErrorItemFixButton(syncSupported: false) {
+ alert = .syncNotSupportedContactAlert
+ }
+ } else {
+ basicDecryptionErrorItem()
}
- } else if !contactStats.ratchetSyncSupported {
- decryptionErrorItemFixButton(syncSupported: false) {
- alert = .syncNotSupportedContactAlert
+ } else if case let .group(groupInfo) = chat.chatInfo,
+ case let .groupRcv(groupMember) = chatItem.chatDir,
+ let mem = m.getGroupMember(groupMember.groupMemberId),
+ let memberStats = mem.wrapped.activeConn?.connectionStats {
+ if memberStats.ratchetSyncAllowed {
+ decryptionErrorItemFixButton(syncSupported: true) {
+ alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
+ }
+ } else if !memberStats.ratchetSyncSupported {
+ decryptionErrorItemFixButton(syncSupported: false) {
+ alert = .syncNotSupportedMemberAlert
+ }
+ } else {
+ basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
}
- } else if case let .group(groupInfo) = chat.chatInfo,
- case let .groupRcv(groupMember) = chatItem.chatDir,
- let mem = m.getGroupMember(groupMember.groupMemberId),
- let memberStats = mem.wrapped.activeConn?.connectionStats {
- if memberStats.ratchetSyncAllowed {
- decryptionErrorItemFixButton(syncSupported: true) {
- alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
- }
- } else if !memberStats.ratchetSyncSupported {
- decryptionErrorItemFixButton(syncSupported: false) {
- alert = .syncNotSupportedMemberAlert
- }
- } else {
- basicDecryptionErrorItem()
- }
- } else {
- basicDecryptionErrorItem()
}
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
}
private func basicDecryptionErrorItem() -> some View {
@@ -117,12 +121,12 @@ struct CIRcvDecryptionError: View {
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
+ + textSpace
+ Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
}
.padding(.horizontal, 12)
@@ -131,7 +135,6 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -141,8 +144,8 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -150,7 +153,6 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index 4670fc685f..851b90bc3d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -292,30 +292,22 @@ struct CIVideoView: View {
.clipShape(Circle())
}
- private func durationProgress() -> some View {
- HStack {
- Text("\(durationText(videoPlaying ? progress : duration))")
- .foregroundColor(.white)
- .font(.caption)
- .padding(.vertical, 3)
- .padding(.horizontal, 6)
- .background(Color.black.opacity(0.35))
- .cornerRadius(10)
- .padding([.top, .leading], 6)
-
- if let file = chatItem.file, !videoPlaying {
- Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
- .foregroundColor(.white)
- .font(.caption)
- .padding(.vertical, 3)
- .padding(.horizontal, 6)
- .background(Color.black.opacity(0.35))
- .cornerRadius(10)
- .padding(.top, 6)
- }
+ private var fileSizeString: String {
+ if let file = chatItem.file, !videoPlaying {
+ " " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
+ } else {
+ ""
}
}
+ private func durationProgress() -> some View {
+ Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
+ .invertedForegroundStyle()
+ .font(.caption)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ }
+
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
return ZStack(alignment: .topTrailing) {
@@ -411,9 +403,9 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
+ .invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
- .foregroundColor(.white)
.padding(smallView ? 0 : padding)
}
@@ -428,10 +420,8 @@ struct CIVideoView: View {
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle()
.trim(from: 0, to: Double(progress) / Double(total))
- .stroke(
- Color(uiColor: .white),
- style: StrokeStyle(lineWidth: 2)
- )
+ .stroke(style: StrokeStyle(lineWidth: 2))
+ .invertedForegroundStyle()
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 45a20f03bd..acecaaae4f 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -510,10 +510,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index 64a7f29a25..47a2cbb6cb 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -92,12 +92,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
- ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
+ 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."))
+ ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 258e2e34dc..9b71e6c4a4 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -12,11 +12,10 @@ import SimpleXChat
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @EnvironmentObject var scrollModel: ReverseListScrollModel
+ @EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat
var chatItem: ChatItem
var preview: UIImage?
- @Binding var revealed: Bool
var maxWidth: CGFloat = .infinity
@State var msgWidth: CGFloat = 0
var imgWidth: CGFloat? = nil
@@ -59,20 +58,25 @@ struct FramedItemView: View {
framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true)
}
- ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView)
+ ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
- if chatItem.content.msgContent != nil {
- CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
- .padding(.horizontal, 12)
- .padding(.bottom, 6)
- .overlay(DetermineWidth())
- .accessibilityLabel("")
+ if let content = chatItem.content.msgContent {
+ CIMetaView(
+ chat: chat,
+ chatItem: chatItem,
+ metaColor: theme.colors.secondary,
+ invertedMaterial: useWhiteMetaColor
+ )
+ .padding(.horizontal, 12)
+ .padding(.bottom, 6)
+ .overlay(DetermineWidth())
+ .accessibilityLabel("")
}
- }
- .background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
+ }
+ .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
@@ -185,7 +189,7 @@ struct FramedItemView: View {
let v = ZStack(alignment: .topTrailing) {
switch (qi.content) {
case let .image(_, image):
- if let uiImage = UIImage(base64Encoded: image) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -197,7 +201,7 @@ struct FramedItemView: View {
ciQuotedMsgView(qi)
}
case let .video(_, image, _):
- if let uiImage = UIImage(base64Encoded: image) {
+ if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
@@ -294,7 +298,6 @@ struct FramedItemView: View {
.padding(.horizontal, 12)
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
- .textSelection(.enabled)
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
@@ -367,14 +370,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
struct FramedItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}
@@ -383,17 +386,18 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true))
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
}
}
@@ -401,17 +405,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true))
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
index a80c5412b6..044ee2a26d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
- @EnvironmentObject var scrollModel: ReverseListScrollModel
+ @EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem
@State var image: UIImage?
@State var player: AVPlayer? = nil
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
index 822dda4d06..afeb88b05d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -69,7 +69,7 @@ struct CIMsgError: View {
}
.padding(.leading, 12)
.padding(.vertical, 6)
- .background(Color(uiColor: .tertiarySystemGroupedBackground))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
.onTapGesture(perform: onTap)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
index 25e06b9ea4..c2b4021edc 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -12,17 +12,17 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
- @Binding var revealed: Bool
var body: some View {
- (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
+ (Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(chatItemFrameColor(chatItem, theme))
+ .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
}
@@ -79,7 +79,10 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
- MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
+ MarkedDeletedItemView(
+ chat: Chat.sampleData,
+ chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
+ ).environment(\.revealed, true)
}
.previewLayout(.fixed(width: 360, height: 200))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 999f99b294..914f7c8a2f 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -11,7 +11,7 @@ import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
-private let noTyping = Text(" ")
+private let noTyping = Text(verbatim: " ")
private let typingIndicators: [Text] = [
(typing(.black) + typing() + typing()),
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View {
@ObservedObject var chat: Chat
+ @Environment(\.showTimestamp) var showTimestamp: Bool
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
@@ -84,7 +85,7 @@ struct MsgContentView: View {
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
- (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
+ (rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
}
}
@@ -103,7 +104,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
}
if let i = icon {
- res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
+ res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res
}
if let s = sender {
diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
index 32993d1a76..587957cd5d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
@@ -14,12 +14,11 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
- var ci: ChatItem
+ var chatItems: [ChatItem]
var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState
@State private var searchText: String = ""
- @FocusState private var searchFocused
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
@@ -46,8 +45,6 @@ struct ChatItemForwardingView: View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
- searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
- .padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in
@@ -55,6 +52,7 @@ struct ChatItemForwardingView: View {
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
+ .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.modifier(ThemedBackground(grouped: true))
} else {
ZStack {
@@ -73,11 +71,14 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
- let prohibited = chat.prohibitedByPref(
- hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
- isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
- isVoice: ci.content.msgContent?.isVoice ?? false
- )
+ let prohibited = chatItems.map { ci in
+ chat.prohibitedByPref(
+ hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
+ isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
+ isVoice: ci.content.msgContent?.isVoice ?? false
+ )
+ }.contains(true)
+
Button {
if prohibited {
alert = SomeAlert(
@@ -93,10 +94,10 @@ struct ChatItemForwardingView: View {
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
- contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
+ contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
)
} else {
- composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
+ composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
@@ -123,7 +124,7 @@ struct ChatItemForwardingView: View {
#Preview {
ChatItemForwardingView(
- ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index f6a856dad1..62ea607d27 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -450,20 +450,8 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
- let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
- switch status {
- case .rcvd:
- ZStack(alignment: .trailing) {
- Image(systemName: icon)
- .foregroundColor(statusColor.opacity(0.67))
- .padding(.trailing, 6)
- Image(systemName: icon)
- .foregroundColor(statusColor.opacity(0.67))
- }
- default:
- Image(systemName: icon)
- .foregroundColor(statusColor)
- }
+ let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
+ image.foregroundColor(statusColor)
}
if let (title, text) = status.statusInfo {
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index 870fe30108..ebbc55a932 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -9,12 +9,33 @@
import SwiftUI
import SimpleXChat
+extension EnvironmentValues {
+ struct ShowTimestamp: EnvironmentKey {
+ static let defaultValue: Bool = true
+ }
+
+ struct Revealed: EnvironmentKey {
+ static let defaultValue: Bool = true
+ }
+
+ var showTimestamp: Bool {
+ get { self[ShowTimestamp.self] }
+ set { self[ShowTimestamp.self] = newValue }
+ }
+
+ var revealed: Bool {
+ get { self[Revealed.self] }
+ set { self[Revealed.self] = newValue }
+ }
+}
+
struct ChatItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
+ @Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
- @Binding var revealed: Bool
@Binding var allowMenu: Bool
init(
@@ -22,27 +43,25 @@ struct ChatItemView: View {
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
- revealed: Binding,
allowMenu: Binding = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
- _revealed = revealed
_allowMenu = allowMenu
}
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
- MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
+ MarkedDeletedItemView(chat: chat, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
- ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
+ ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -60,7 +79,7 @@ struct ChatItemView: View {
default: nil
}
}
- .flatMap { UIImage(base64Encoded: $0) }
+ .flatMap { imageFromBase64($0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
@@ -72,7 +91,6 @@ struct ChatItemView: View {
chat: chat,
chatItem: chatItem,
preview: preview,
- revealed: $revealed,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
@@ -84,9 +102,9 @@ struct ChatItemView: View {
struct ChatItemContentView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
+ @Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
- @Binding var revealed: Bool
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -118,7 +136,7 @@ struct ChatItemContentView: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
- CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
+ CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -152,7 +170,7 @@ struct ChatItemContentView: View {
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
- return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
+ return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
@@ -165,7 +183,7 @@ struct ChatItemContentView: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
- CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
+ CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -185,7 +203,7 @@ struct ChatItemContentView: View {
} else if ns.count == 0 {
Text("\(count) group events")
} else if count > ns.count {
- Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
+ Text(members) + textSpace + Text("and \(count - ns.count) other events")
} else {
Text(members)
}
@@ -216,7 +234,7 @@ func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
- chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
+ chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
@@ -226,16 +244,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
- ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
+ ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
}
+ .environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
@@ -253,8 +272,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
- ),
- revealed: Binding.constant(true)
+ )
)
ChatItemView(
chat: Chat.sampleData,
@@ -264,8 +282,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
quotedItem: nil,
file: nil
- ),
- revealed: Binding.constant(true)
+ )
)
ChatItemView(
chat: Chat.sampleData,
@@ -275,8 +292,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
- ),
- revealed: Binding.constant(true)
+ )
)
ChatItemView(
chat: Chat.sampleData,
@@ -286,8 +302,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
- ),
- revealed: Binding.constant(true)
+ )
)
ChatItemView(
chat: Chat.sampleData,
@@ -297,10 +312,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: ciFeatureContent,
quotedItem: nil,
file: nil
- ),
- revealed: Binding.constant(true)
+ )
)
}
+ .environment(\.revealed, true)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 7bd13a3fa7..0c5a458930 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -22,8 +22,7 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
- @StateObject private var scrollModel = ReverseListScrollModel()
- @StateObject private var floatingButtonModel = FloatingButtonModel()
+ @StateObject private var scrollModel = ReverseListScrollModel()
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -43,6 +42,7 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
+ @State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set? = nil
@State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
@@ -53,7 +53,6 @@ struct ChatView: View {
if #available(iOS 16.0, *) {
viewBody
.scrollDismissesKeyboard(.immediately)
- .keyboardPadding()
.toolbarBackground(.hidden, for: .navigationBar)
} else {
viewBody
@@ -77,7 +76,7 @@ struct ChatView: View {
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
chatItemsList()
- floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
+ FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
}
connectingText()
if selectedChatItems == nil {
@@ -100,7 +99,8 @@ struct ChatView: View {
if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
- }
+ },
+ forwardItems: forwardSelectedMessages
)
}
}
@@ -133,10 +133,39 @@ struct ChatView: View {
.appSheet(item: $selectedMember) { member in
Group {
if case let .group(groupInfo) = chat.chatInfo {
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: member,
+ navigation: true
+ )
}
}
}
+ // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
+ .appSheet(isPresented: $showAddMembersSheet) {
+ Group {
+ if case let .group(groupInfo) = cInfo {
+ AddGroupMembersView(chat: chat, groupInfo: groupInfo)
+ }
+ }
+ }
+ .sheet(isPresented: Binding(
+ get: { !forwardedChatItems.isEmpty },
+ set: { isPresented in
+ if !isPresented {
+ forwardedChatItems = []
+ selectedChatItems = nil
+ }
+ }
+ )) {
+ if #available(iOS 16.0, *) {
+ ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
+ .presentationDetents([.fraction(0.8)])
+ } else {
+ ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
+ }
+ }
.onAppear {
selectedChatItems = nil
initChatView()
@@ -202,6 +231,8 @@ struct ChatView: View {
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias,
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
onSearch: { focusSearch() }
)
}
@@ -288,9 +319,6 @@ struct ChatView: View {
}
} else {
addMembersButton()
- .appSheet(isPresented: $showAddMembersSheet) {
- AddGroupMembersView(chat: chat, groupInfo: groupInfo)
- }
}
}
Menu {
@@ -341,6 +369,7 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false)
}
}
+ ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
}
private func searchToolbar() -> some View {
@@ -412,14 +441,9 @@ struct ChatView: View {
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem,
- selectedChatItems: $selectedChatItems
+ selectedChatItems: $selectedChatItems,
+ forwardedChatItems: $forwardedChatItems
)
- .onAppear {
- floatingButtonModel.appeared(viewId: ci.viewId)
- }
- .onDisappear {
- floatingButtonModel.disappeared(viewId: ci.viewId)
- }
.id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: {
loadChatItems(cInfo)
@@ -430,13 +454,10 @@ struct ChatView: View {
.onChange(of: searchText) { _ in
Task { await loadChat(chat: chat, search: searchText) }
}
- .onChange(of: im.reversedChatItems) { _ in
- floatingButtonModel.chatItemsChanged()
- }
.onChange(of: im.itemAdded) { added in
if added {
im.itemAdded = false
- if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom {
+ if FloatingButtonModel.shared.isReallyNearBottom {
scrollModel.scrollToBottom()
}
}
@@ -459,103 +480,169 @@ struct ChatView: View {
}
class FloatingButtonModel: ObservableObject {
- private enum Event {
- case appeared(String)
- case disappeared(String)
- case chatItemsChanged
- }
+ static let shared = FloatingButtonModel()
+ @Published var unreadBelow: Int = 0
+ @Published var isNearBottom: Bool = true
+ @Published var date: Date?
+ @Published var isDateVisible: Bool = false
+ var totalUnread: Int = 0
+ var isReallyNearBottom: Bool = true
+ var hideDateWorkItem: DispatchWorkItem?
- @Published var unreadChatItemCounts: UnreadChatItemCounts
-
- private let events = PassthroughSubject()
- private var bag = Set()
-
- init() {
- unreadChatItemCounts = UnreadChatItemCounts(
- isNearBottom: true,
- isReallyNearBottom: true,
- unreadBelow: 0
- )
- events
- .receive(on: DispatchQueue.global(qos: .background))
- .scan(Set()) { itemsInView, event in
- var updated = itemsInView
- switch event {
- case let .appeared(viewId): updated.insert(viewId)
- case let .disappeared(viewId): updated.remove(viewId)
- case .chatItemsChanged: ()
- }
- return updated
+ func updateOnListChange(_ listState: ListState) {
+ let im = ItemsModel.shared
+ let unreadBelow =
+ if let id = listState.bottomItemId,
+ let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
+ {
+ im.reversedChatItems[.. 0 && listState.scrollOffset < 500
+ }
+
+ // set floating button indication mode
+ let nearBottom = listState.scrollOffset < 800
+ if nearBottom != self.isNearBottom {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
+ self?.isNearBottom = nearBottom
+ }
+ }
+
+ // hide Date indicator after 1 second of no scrolling
+ hideDateWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let it = self else { return }
+ it.setDate(visibility: false)
+ it.hideDateWorkItem = nil
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.hideDateWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
+ }
}
- func appeared(viewId: String) {
- events.send(.appeared(viewId))
+ func resetDate() {
+ date = nil
+ isDateVisible = false
}
- func disappeared(viewId: String) {
- events.send(.disappeared(viewId))
+ private func setDate(visibility isVisible: Bool) {
+ if isVisible {
+ if !isNearBottom,
+ !isDateVisible,
+ let date, !Calendar.current.isDateInToday(date) {
+ withAnimation { self.isDateVisible = true }
+ }
+ } else if isDateVisible {
+ withAnimation { self.isDateVisible = false }
+ }
}
- func chatItemsChanged() {
- events.send(.chatItemsChanged)
- }
}
- private func floatingButtons(counts: UnreadChatItemCounts) -> some View {
- VStack {
- let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
- if unreadAbove > 0 {
- circleButton {
- unreadCountText(unreadAbove)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
+ private struct FloatingButtons: View {
+ let theme: AppTheme
+ let scrollModel: ReverseListScrollModel
+ let chat: Chat
+ @ObservedObject var model = FloatingButtonModel.shared
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ if let date = model.date {
+ DateSeparator(date: date)
+ .padding(.vertical, 4).padding(.horizontal, 8)
+ .background(.thinMaterial)
+ .clipShape(Capsule())
+ .opacity(model.isDateVisible ? 1 : 0)
}
- .onTapGesture {
- scrollModel.scrollToNextPage()
- }
- .contextMenu {
- Button {
- Task {
- await markChatRead(chat)
+ VStack {
+ let unreadAbove = model.totalUnread - model.unreadBelow
+ if unreadAbove > 0 {
+ circleButton {
+ unreadCountText(unreadAbove)
+ .font(.callout)
+ .foregroundColor(theme.colors.primary)
}
- } label: {
- Label("Mark read", systemImage: "checkmark")
+ .onTapGesture {
+ scrollModel.scrollToNextPage()
+ }
+ .contextMenu {
+ Button {
+ Task {
+ await markChatRead(chat)
+ }
+ } label: {
+ Label("Mark read", systemImage: "checkmark")
+ }
+ }
+ }
+ Spacer()
+ if model.unreadBelow > 0 {
+ circleButton {
+ unreadCountText(model.unreadBelow)
+ .font(.callout)
+ .foregroundColor(theme.colors.primary)
+ }
+ .onTapGesture {
+ scrollModel.scrollToBottom()
+ }
+ } else if !model.isNearBottom {
+ circleButton {
+ Image(systemName: "chevron.down")
+ .foregroundColor(theme.colors.primary)
+ }
+ .onTapGesture { scrollModel.scrollToBottom() }
}
}
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
- Spacer()
- if counts.unreadBelow > 0 {
- circleButton {
- unreadCountText(counts.unreadBelow)
- .font(.callout)
- .foregroundColor(theme.colors.primary)
- }
- .onTapGesture {
- scrollModel.scrollToBottom()
- }
- } else if !counts.isNearBottom {
- circleButton {
- Image(systemName: "chevron.down")
- .foregroundColor(theme.colors.primary)
- }
- .onTapGesture { scrollModel.scrollToBottom() }
+ .onDisappear(perform: model.resetDate)
+ }
+
+ private func circleButton(_ content: @escaping () -> Content) -> some View {
+ ZStack {
+ Circle()
+ .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
+ .frame(width: 44, height: 44)
+ content()
}
}
- .padding()
}
- private func circleButton(_ content: @escaping () -> Content) -> some View {
- ZStack {
- Circle()
- .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
- .frame(width: 44, height: 44)
- content()
+ private struct DateSeparator: View {
+ let date: Date
+
+ var body: some View {
+ Text(String.localizedStringWithFormat(
+ NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
+ date.formatted(.dateTime.weekday(.abbreviated)),
+ date.formatted(
+ Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
+ ? .dateTime.day().month(.abbreviated)
+ : .dateTime.day().month(.abbreviated).year()
+ )
+ ))
+ .font(.callout)
+ .fontWeight(.medium)
+ .foregroundStyle(.secondary)
}
}
@@ -569,8 +656,8 @@ struct ChatView: View {
private func endCallButton(_ call: Call) -> some View {
Button {
- if let uuid = call.callkitUUID {
- CallController.shared.endCall(callUUID: uuid)
+ if CallController.useCallKit(), let callUUID = call.callUUID {
+ CallController.shared.endCall(callUUID: callUUID)
} else {
CallController.shared.endCall(call: call) {}
}
@@ -649,6 +736,116 @@ struct ChatView: View {
}
}
+ private func forwardSelectedMessages() {
+ Task {
+ do {
+ if let selectedChatItems {
+ let (validItems, confirmation) = try await apiPlanForwardChatItems(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemIds: Array(selectedChatItems)
+ )
+ if let confirmation {
+ if validItems.count > 0 {
+ showAlert(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Forward %d message(s)?", comment: "alert title"),
+ validItems.count
+ ),
+ message: forwardConfirmationText(confirmation) + "\n" +
+ NSLocalizedString("Forward messages without files?", comment: "alert message")
+ ) {
+ switch confirmation {
+ case let .filesNotAccepted(fileIds):
+ [forwardAction(validItems), downloadAction(fileIds), cancelAlertAction]
+ default:
+ [forwardAction(validItems), cancelAlertAction]
+ }
+ }
+ } else {
+ showAlert(
+ NSLocalizedString("Nothing to forward!", comment: "alert title"),
+ message: forwardConfirmationText(confirmation)
+ ) {
+ switch confirmation {
+ case let .filesNotAccepted(fileIds):
+ [downloadAction(fileIds), cancelAlertAction]
+ default:
+ [okAlertAction]
+ }
+ }
+ }
+ } else {
+ await openForwardingSheet(validItems)
+ }
+ }
+ } catch {
+ logger.error("Plan forward chat items failed: \(error.localizedDescription)")
+ }
+ }
+
+ func forwardConfirmationText(_ fc: ForwardConfirmation) -> String {
+ switch fc {
+ case let .filesNotAccepted(fileIds):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"),
+ fileIds.count
+ )
+ case let .filesInProgress(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ case let .filesMissing(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ case let .filesFailed(filesCount):
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"),
+ filesCount
+ )
+ }
+ }
+
+ func forwardAction(_ items: [Int64]) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Forward messages", comment: "alert action"),
+ style: .default,
+ handler: { _ in Task { await openForwardingSheet(items) } }
+ )
+ }
+
+ func downloadAction(_ fileIds: [Int64]) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Download files", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ Task {
+ if let user = ChatModel.shared.currentUser {
+ await receiveFiles(user: user, fileIds: fileIds)
+ }
+ }
+ }
+ )
+ }
+
+ func openForwardingSheet(_ items: [Int64]) async {
+ let im = ItemsModel.shared
+ var items = Set(items)
+ var fci = [ChatItem]()
+ for reversedChatItem in im.reversedChatItems {
+ if items.contains(reversedChatItem.id) {
+ items.remove(reversedChatItem.id)
+ fci.insert(reversedChatItem, at: 0)
+ }
+ if items.isEmpty { break }
+ }
+ await MainActor.run { forwardedChatItems = fci }
+ }
+ }
+
private func loadChatItems(_ cInfo: ChatInfo) {
Task {
if loadingItems || firstPage { return }
@@ -697,6 +894,7 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding @ObservedObject var chat: Chat
+ @ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@@ -709,39 +907,66 @@ struct ChatView: View {
@State private var showDeleteMessages = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
- @State private var showForwardingSheet: Bool = false
+ @State private var msgWidth: CGFloat = 0
@Binding var selectedChatItems: Set?
+ @Binding var forwardedChatItems: [ChatItem]
@State private var allowMenu: Bool = true
+ @State private var markedRead = false
var revealed: Bool { chatItem == revealedChatItem }
+ typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
+
+ func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
+ let im = ItemsModel.shared
+ if let i, i > 0 && im.reversedChatItems.count >= i {
+ let nextItem = im.reversedChatItems[i - 1]
+ let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
+ return (
+ timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
+ largeGap: largeGap,
+ date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
+ )
+ } else {
+ return (timestamp: true, largeGap: true, date: nil)
+ }
+ }
+
var body: some View {
- let (currIndex, _) = m.getNextChatItem(chatItem)
+ let currIndex = m.getChatItemIndex(chatItem)
let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
+ let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range]))
- ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in
- let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
- chatItemView(ci, nil, prev)
- .overlay {
- if let selected = selectedChatItems, ci.canBeDeletedForSelf {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture {
- let checked = selected.contains(ci.id)
- selectUnselectChatItem(select: !checked, ci)
+ VStack(spacing: 0) {
+ ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
+ let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
+ chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
+ .overlay {
+ if let selected = selectedChatItems, ci.canBeDeletedForSelf {
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture {
+ let checked = selected.contains(ci.id)
+ selectUnselectChatItem(select: !checked, ci)
+ }
}
- }
+ }
}
}
} else {
- chatItemView(chatItem, range, prevItem)
+ VStack(spacing: 0) {
+ chatItemView(chatItem, range, prevItem, timeSeparation)
+ if let date = timeSeparation.date {
+ DateSeparator(date: date).padding(8)
+ }
+ }
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
@@ -755,44 +980,94 @@ struct ChatView: View {
}
}
.onAppear {
+ if markedRead {
+ return
+ } else {
+ markedRead = true
+ }
if let range {
- if let items = unreadItems(range) {
+ let itemIds = unreadItemIds(range)
+ if !itemIds.isEmpty {
waitToMarkRead {
- for ci in items {
- await apiMarkChatItemRead(chat.chatInfo, ci)
- }
+ await apiMarkChatItemsRead(chat.chatInfo, itemIds)
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
- await apiMarkChatItemRead(chat.chatInfo, chatItem)
+ await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
}
}
}
}
-
- private func unreadItems(_ range: ClosedRange) -> [ChatItem]? {
+
+ private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] {
let im = ItemsModel.shared
- let items = range.compactMap { i in
+ return range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
- return if ci.isRcvNew { ci } else { nil }
+ return if ci.isRcvNew { ci.id } else { nil }
} else {
return nil
}
}
- return if items.isEmpty { nil } else { items }
}
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
+ Task {
+ _ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id {
- Task(operation: op)
+ await op()
}
}
}
+
- @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View {
+ @available(iOS 16.0, *)
+ struct MemberLayout: Layout {
+ let spacing: Double
+ let msgWidth: Double
+
+ private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
+ assert(subviews.count == 2, "member layout must contain exactly two subviews")
+ let roleSize = subviews[1].sizeThatFits(proposal)
+ let memberSize = subviews[0].sizeThatFits(
+ ProposedViewSize(
+ width: (proposal.width ?? msgWidth) - roleSize.width,
+ height: proposal.height
+ )
+ )
+ return (memberSize, roleSize)
+ }
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
+ let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
+ return CGSize(
+ width: min(
+ proposal.width ?? msgWidth,
+ max(msgWidth, roleSize.width + spacing + memberSize.width)
+ ),
+ height: max(memberSize.height, roleSize.height)
+ )
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
+ let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
+ subviews[0].place(
+ at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
+ proposal: ProposedViewSize(memberSize)
+ )
+ subviews[1].place(
+ at: CGPoint(
+ x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
+ y: bounds.midY - roleSize.height / 2
+ ),
+ proposal: ProposedViewSize(roleSize)
+ )
+ }
+ }
+
+ @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
+ let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
@@ -804,41 +1079,66 @@ struct ChatView: View {
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
- let t = if memCount == 1 && member.memberRole > .member {
- Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
- } else {
- Text(memberNames(member, prevMember, memCount))
+ Group {
+ if memCount == 1 && member.memberRole > .member {
+ Group {
+ if #available(iOS 16.0, *) {
+ MemberLayout(spacing: 16, msgWidth: msgWidth) {
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Text(member.memberRole.text)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ .padding(.trailing, 8)
+ }
+ } else {
+ HStack(spacing: 16) {
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Text(member.memberRole.text)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ .layoutPriority(1)
+ }
+ }
+ }
+ .frame(
+ maxWidth: maxWidth,
+ alignment: chatItem.chatDir.sent ? .trailing : .leading
+ )
+ } else {
+ Text(memberNames(member, prevMember, memCount))
+ .lineLimit(2)
+ }
}
- t
.font(.caption)
.foregroundStyle(.secondary)
- .lineLimit(2)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
- .padding(.top, 7)
+ .padding(.top, 3) // this is in addition to message sequence gap
}
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
- HStack(alignment: .top, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
- if let member = m.getGroupMember(member.groupMemberId) {
- selectedMember = member
+ if let mem = m.getGroupMember(member.groupMemberId) {
+ selectedMember = mem
} else {
- Task {
- await m.loadGroupMembers(groupInfo) {
- selectedMember = m.getGroupMember(member.groupMemberId)
- }
- }
+ let mem = GMember.init(member)
+ m.groupMembers.append(mem)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = mem
}
}
- chatItemWithMenu(ci, range, maxWidth)
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
+ .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
}
}
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
.padding(.trailing)
.padding(.leading, 12)
} else {
@@ -847,11 +1147,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
- chatItemWithMenu(ci, range, maxWidth)
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
- .padding(.leading, memberImageSize + 8 + 12)
+ .padding(.leading, 10 + memberImageSize + 12)
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
}
} else {
HStack(alignment: .center, spacing: 0) {
@@ -864,10 +1164,10 @@ struct ChatView: View {
.padding(.leading)
}
}
- chatItemWithMenu(ci, range, maxWidth)
+ chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.horizontal)
}
- .padding(.bottom, 5)
+ .padding(.bottom, bottomPadding)
}
}
@@ -882,17 +1182,18 @@ struct ChatView: View {
}
}
- @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View {
+ @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(
chat: chat,
chatItem: ci,
maxWidth: maxWidth,
- revealed: .constant(revealed),
allowMenu: $allowMenu
)
- .modifier(ChatItemClipped(ci))
+ .environment(\.revealed, revealed)
+ .environment(\.showTimestamp, itemSeparation.timestamp)
+ .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -922,14 +1223,6 @@ struct ChatView: View {
}) {
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
}
- .sheet(isPresented: $showForwardingSheet) {
- if #available(iOS 16.0, *) {
- ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
- .presentationDetents([.fraction(0.8)])
- } else {
- ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
- }
- }
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
@@ -957,11 +1250,20 @@ struct ChatView: View {
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
-
- if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
+ .if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
v.onTapGesture {
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
}
+ }
+ if case let .group(groupInfo) = chat.chatInfo {
+ v.contextMenu {
+ ReactionContextMenu(
+ groupInfo: groupInfo,
+ itemId: ci.id,
+ reactionCount: r,
+ selectedMember: $selectedMember
+ )
+ }
} else {
v
}
@@ -1070,7 +1372,7 @@ struct ChatView: View {
var forwardButton: Button {
Button {
- showForwardingSheet = true
+ forwardedChatItems = [chatItem]
} label: {
Label(
NSLocalizedString("Forward", comment: "chat item action"),
@@ -1551,6 +1853,109 @@ private func buildTheme() -> AppTheme {
}
}
+struct ReactionContextMenu: View {
+ @EnvironmentObject var m: ChatModel
+ let groupInfo: GroupInfo
+ var itemId: Int64
+ var reactionCount: CIReactionCount
+ @Binding var selectedMember: GMember?
+ @State private var memberReactions: [MemberReaction] = []
+ @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
+
+ var body: some View {
+ groupMemberReactionList()
+ .task {
+ logger.debug("ReactionContextMenu task \(radius)")
+ await loadChatItemReaction()
+ }
+ }
+
+ @ViewBuilder private func groupMemberReactionList() -> some View {
+ if memberReactions.isEmpty {
+ ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
+ Text(verbatim: " ")
+ }
+ } else {
+ ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in
+ let mem = mr.groupMember
+ let userMember = mem.groupMemberId == groupInfo.membership.groupMemberId
+ Button {
+ if let member = m.getGroupMember(mem.groupMemberId) {
+ selectedMember = member
+ } else {
+ let member = GMember.init(mem)
+ m.groupMembers.append(member)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = member
+ }
+ } label: {
+ HStack {
+ Text(mem.displayName)
+ if let img = cropImage(mem.image) {
+ Image(uiImage: img)
+ } else {
+ Image(systemName: "person.crop.circle")
+ }
+ }
+ }
+ .disabled(userMember)
+ }
+ }
+ }
+
+ private func cropImage(_ img: String?) -> UIImage? {
+ return if let originalImage = imageFromBase64(img) {
+ maskToCustomShape(originalImage, size: 30, radius: radius)
+ } else {
+ nil
+ }
+ }
+
+ private func loadChatItemReaction() async {
+ do {
+ let memberReactions = try await apiGetReactionMembers(
+ groupId: groupInfo.groupId,
+ itemId: itemId,
+ reaction: reactionCount.reaction
+ )
+ await MainActor.run {
+ self.memberReactions = memberReactions
+ }
+ } catch let error {
+ logger.error("apiGetReactionMembers error: \(responseError(error))")
+ }
+ }
+}
+
+func maskToCustomShape(_ image: UIImage, size: CGFloat, radius: CGFloat) -> UIImage {
+ let path = Path { path in
+ if radius >= 50 {
+ path.addEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
+ } else if radius <= 0 {
+ path.addRect(CGRect(x: 0, y: 0, width: size, height: size))
+ } else {
+ let cornerRadius = size * CGFloat(radius) / 100
+ path.addRoundedRect(
+ in: CGRect(x: 0, y: 0, width: size, height: size),
+ cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
+ style: .continuous
+ )
+ }
+ }
+
+ return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in
+ context.cgContext.addPath(path.cgPath)
+ context.cgContext.clip()
+ let scale = size / max(image.size.width, image.size.height)
+ let imageSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ let imageOrigin = CGPoint(
+ x: (size - imageSize.width) / 2,
+ y: (size - imageSize.height) / 2
+ )
+ image.draw(in: CGRect(origin: imageOrigin, size: imageSize))
+ }
+}
+
struct ToggleNtfsButton: View {
@ObservedObject var chat: Chat
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
index df3a8caf55..14026d79d1 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift
@@ -18,7 +18,7 @@ struct ComposeImageView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in
- UIImage(base64Encoded: image)
+ imageFromBase64(image)
}
if imgs.count == 0 {
ProgressView()
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
index 66cb9edcf8..6c44aeea83 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
@@ -40,7 +40,7 @@ struct ComposeLinkView: View {
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) {
- if let uiImage = UIImage(base64Encoded: linkPreview.image) {
+ if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -55,7 +55,7 @@ struct ComposeLinkView: View {
.foregroundColor(theme.colors.secondary)
}
.padding(.vertical, 5)
- .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
+ .frame(maxWidth: .infinity, minHeight: 60)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index e059d72e98..19e2b528f1 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -23,7 +23,7 @@ enum ComposeContextItem {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
- case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
+ case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
}
enum VoiceMessageRecordingState {
@@ -73,10 +73,10 @@ struct ComposeState {
}
}
- init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
+ init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = ""
self.preview = .noPreview
- self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
+ self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
}
@@ -112,7 +112,7 @@ struct ComposeState {
var forwarding: Bool {
switch contextItem {
- case .forwardingItem: return true
+ case .forwardingItems: return true
default: return false
}
}
@@ -167,6 +167,13 @@ struct ComposeState {
}
}
+ var manyMediaPreviews: Bool {
+ switch preview {
+ case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
+ default: return false
+ }
+ }
+
var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress { return true }
switch preview {
@@ -280,8 +287,8 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
- @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
- @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
VStack(spacing: 0) {
@@ -382,7 +389,11 @@ struct ComposeView: View {
}
}
}
- .background(ToolbarMaterial.material(toolbarMaterial))
+ .background {
+ Color.clear
+ .overlay(ToolbarMaterial.material(toolbarMaterial))
+ .ignoresSafeArea(.all, edges: .bottom)
+ }
.onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed {
if msg.count > 0 {
@@ -448,7 +459,7 @@ struct ComposeView: View {
Task {
var media: [(String, UploadContent)] = []
for content in selected {
- if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
+ if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
media.append((img, content))
await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
@@ -540,7 +551,7 @@ struct ComposeView: View {
}
private func addMediaContent(_ content: UploadContent) async {
- if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
+ if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content))
@@ -683,7 +694,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem):
ContextItemView(
chat: chat,
- contextItem: quotedItem,
+ contextItems: [quotedItem],
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
@@ -691,18 +702,17 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
- contextItem: editingItem,
+ contextItems: [editingItem],
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
Divider()
- case let .forwardingItem(chatItem: forwardedItem, _):
+ case let .forwardingItems(chatItems, _):
ContextItemView(
chat: chat,
- contextItem: forwardedItem,
+ contextItems: chatItems,
contextIcon: "arrowshape.turn.up.forward",
- cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
- showSender: false
+ cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
}
@@ -726,10 +736,11 @@ struct ComposeView: View {
}
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation()
- } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
- sent = await forwardItem(ci, fromChatInfo, ttl)
+ } else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
+ // Composed text is send as a reply to the last forwarded item
+ sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
- sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
+ _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
@@ -746,26 +757,28 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
- case let .mediaPreviews(mediaPreviews: media):
+ case let .mediaPreviews(media):
let last = media.count - 1
+ var msgs: [ComposedMessage] = []
if last >= 0 {
for i in 0.. 0 {
+ // Sleep to allow `progressByTimeout` update be rendered
+ try? await Task.sleep(nanoseconds: 100_000000)
+ }
+ if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
+ msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
}
- _ = try? await Task.sleep(nanoseconds: 100_000000)
}
- if case (_, .video(_, _, _)) = media[last] {
- sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
- } else {
- sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
+ if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
+ msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
}
}
- if sent == nil {
- sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
+ if msgs.isEmpty {
+ msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
}
+ sent = await send(msgs, live: live, ttl: ttl).last
+
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
@@ -787,6 +800,20 @@ struct ComposeView: View {
}
return sent
+ func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
+ let (previewImage, uploadContent) = media
+ return switch uploadContent {
+ case let .simpleImage(image):
+ (saveImage(image), .image(text: text, image: previewImage))
+ case let .animatedImage(image):
+ (saveAnimImage(image), .image(text: text, image: previewImage))
+ case let .video(_, url, duration):
+ (moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
+ case .none:
+ nil
+ }
+ }
+
func sending() async {
await MainActor.run { composeState.inProgress = true }
}
@@ -850,23 +877,6 @@ struct ComposeView: View {
}
}
- func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- let (image, data) = imageData
- if let data = data, let savedFile = saveAnyImage(data) {
- return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
- }
- return nil
- }
-
- func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- let (image, data) = imageData
- if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
- ChatModel.shared.filesToDelete.remove(url)
- return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
- }
- return nil
- }
-
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
if !privacyEncryptLocalFilesGroupDefault.get() {
return CryptoFile.plain(fileName)
@@ -883,44 +893,66 @@ struct ComposeView: View {
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
- if let chatItem = chat.chatInfo.chatType == .local
- ? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
- : await apiSendMessage(
+ await send(
+ [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
+ live: live,
+ ttl: ttl
+ ).first
+ }
+
+ func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
+ if let chatItems = chat.chatInfo.chatType == .local
+ ? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
+ : await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
- file: file,
- quotedItemId: quoted,
- msg: mc,
live: live,
- ttl: ttl
+ ttl: ttl,
+ composedMessages: msgs
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ for chatItem in chatItems {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
}
- return chatItem
+ return chatItems
}
- if let file = file {
- removeFile(file.filePath)
+ for msg in msgs {
+ if let file = msg.fileSource {
+ removeFile(file.filePath)
+ }
}
- return nil
+ return []
}
- func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
- if let chatItem = await apiForwardChatItem(
+ func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
+ if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
- itemId: forwardedItem.id,
+ itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
await MainActor.run {
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ for chatItem in chatItems {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
+ if forwardedItems.count != chatItems.count {
+ showAlert(
+ String.localizedStringWithFormat(
+ NSLocalizedString("%d messages not forwarded", comment: "alert title"),
+ forwardedItems.count - chatItems.count
+ ),
+ message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
+ )
+ }
}
- return chatItem
+ return chatItems
+ } else {
+ return []
}
- return nil
}
func checkLinkPreview() -> MsgContent {
@@ -937,14 +969,6 @@ struct ComposeView: View {
return .text(msgText)
}
}
-
- func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
- switch img {
- case let .simpleImage(image): return saveImage(image)
- case let .animatedImage(image): return saveAnimImage(image)
- default: return nil
- }
- }
}
private func startVoiceMessageRecording() async {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index 6245bbe21f..fa999961fc 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -12,7 +12,7 @@ import SimpleXChat
struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
- let contextItem: ChatItem
+ let contextItems: [ChatItem]
let contextIcon: String
let cancelContextItem: () -> Void
var showSender: Bool = true
@@ -24,13 +24,22 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary)
- if showSender, let sender = contextItem.memberDisplayName {
- VStack(alignment: .leading, spacing: 4) {
- Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
- msgContentView(lines: 2)
- }
+ if let singleItem = contextItems.first, contextItems.count == 1 {
+ if showSender, let sender = singleItem.memberDisplayName {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
+ msgContentView(lines: 2, contextItem: singleItem)
+ }
+ } else {
+ msgContentView(lines: 3, contextItem: singleItem)
+ }
} else {
- msgContentView(lines: 3)
+ Text(
+ chat.chatInfo.chatType == .local
+ ? "Saving \(contextItems.count) messages"
+ : "Forwarding \(contextItems.count) messages"
+ )
+ .italic()
}
Spacer()
Button {
@@ -45,29 +54,38 @@ struct ContextItemView: View {
.padding(12)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
- .background(chatItemFrameColor(contextItem, theme))
+ .background(background)
}
- private func msgContentView(lines: Int) -> some View {
- contextMsgPreview()
+ private var background: Color {
+ contextItems.first
+ .map { chatItemFrameColor($0, theme) }
+ ?? Color(uiColor: .tertiarySystemBackground)
+ }
+
+ private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
+ contextMsgPreview(contextItem)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines)
}
- private func contextMsgPreview() -> Text {
+ private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
func attachment() -> Text {
+ let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
+ FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
+ } else { false }
switch contextItem.content.msgContent {
- case .file: return image("doc.fill")
+ case .file: return isFileLoaded ? image("doc.fill") : Text("")
case .image: return image("photo")
- case .voice: return image("play.fill")
+ case .voice: return isFileLoaded ? image("play.fill") : Text("")
default: return Text("")
}
}
func image(_ s: String) -> Text {
- Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
+ Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + textSpace
}
}
}
@@ -75,6 +93,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
- return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
+ return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index 9ad6e986bd..8880023e02 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -40,7 +40,7 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false
- @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
+ @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
ZStack {
@@ -227,6 +227,7 @@ struct SendMessageView: View {
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
+ !composeState.manyMediaPreviews,
let send = sendLiveMessage,
let update = updateLiveMessage {
Button {
diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
index b3fab958bc..e4489e46ee 100644
--- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
@@ -14,9 +14,10 @@ struct ContactPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contact: Contact
- @State var featuresAllowed: ContactFeaturesAllowed
- @State var currentFeaturesAllowed: ContactFeaturesAllowed
+ @Binding var featuresAllowed: ContactFeaturesAllowed
+ @Binding var currentFeaturesAllowed: ContactFeaturesAllowed
@State private var showSaveDialogue = false
+ let savePreferences: () -> Void
var body: some View {
let user: User = chatModel.currentUser!
@@ -48,7 +49,10 @@ struct ContactPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ featuresAllowed = currentFeaturesAllowed
+ dismiss()
+ }
}
}
@@ -118,31 +122,15 @@ struct ContactPreferencesView: View {
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
Text(feature.enabledDescription(enabled))
}
-
- private func savePreferences() {
- Task {
- do {
- let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
- if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
- await MainActor.run {
- contact = toContact
- chatModel.updateContact(toContact)
- currentFeaturesAllowed = featuresAllowed
- }
- }
- } catch {
- logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
- }
- }
- }
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
contact: Binding.constant(Contact.sampleData),
- featuresAllowed: ContactFeaturesAllowed.sampleData,
- currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
+ featuresAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
+ currentFeaturesAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
+ savePreferences: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
index 189ab95494..bdef8d0a62 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -78,7 +78,12 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count
Section {
if creatingGroup {
- groupPreferencesButton($groupInfo, true)
+ GroupPreferencesButton(
+ groupInfo: $groupInfo,
+ preferences: groupInfo.fullGroupPreferences,
+ currentPreferences: groupInfo.fullGroupPreferences,
+ creatingGroup: true
+ )
}
rolePicker()
inviteMembersButton()
@@ -105,8 +110,10 @@ struct AddGroupMembersViewCommon: View {
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
- ForEach(members) { contact in
- contactCheckView(contact)
+ ForEach(members + [dummyContact]) { contact in
+ if contact.contactId != dummyContact.contactId {
+ contactCheckView(contact)
+ }
}
}
}
@@ -130,12 +137,21 @@ struct AddGroupMembersViewCommon: View {
.modifier(ThemedBackground(grouped: true))
}
- private func inviteMembersButton() -> some View {
+ // Resolves keyboard losing focus bug in iOS16 and iOS17,
+ // when there are no items inside `ForEach(memebers)` loop
+ private let dummyContact: Contact = {
+ var dummy = Contact.sampleData
+ dummy.contactId = -1
+ return dummy
+ }()
+
+ @ViewBuilder private func inviteMembersButton() -> some View {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
Button {
inviteMembers()
} label: {
HStack {
- Text("Invite to group")
+ Text(label)
Image(systemName: "checkmark")
}
}
@@ -221,6 +237,7 @@ func searchFieldView(text: Binding, focussed: FocusState.Binding,
.focused(focussed)
.foregroundColor(onBackgroundColor)
.frame(maxWidth: .infinity)
+ .autocorrectionDisabled(true)
Image(systemName: "xmark.circle.fill")
.resizable()
.scaledToFit()
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 50eb883e92..c4df91bb8b 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -81,13 +81,13 @@ struct GroupChatInfoView: View {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
- if groupInfo.canEdit {
+ if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
}
- if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
+ if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
addOrEditWelcomeMessage()
}
- groupPreferencesButton($groupInfo)
+ GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
@@ -101,13 +101,20 @@ struct GroupChatInfoView: View {
} header: {
Text("")
} footer: {
- Text("Only group owners can change group preferences.")
+ let label: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Only group owners can change group preferences."
+ : "Only chat owners can change preferences."
+ )
+ Text(label)
.foregroundColor(theme.colors.secondary)
}
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
- groupLinkButton()
+ if groupInfo.businessChat == nil {
+ groupLinkButton()
+ }
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
@@ -185,7 +192,6 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
- .keyboardPadding()
}
private func groupInfoHeader() -> some View {
@@ -277,10 +283,15 @@ struct GroupChatInfoView: View {
}
private func addMembersButton() -> some View {
- NavigationLink {
+ let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
+ case .customer: "Add team members"
+ case .business: "Add friends"
+ case .none: "Invite members"
+ }
+ return NavigationLink {
addMembersDestinationView()
} label: {
- Label("Invite members", systemImage: "plus")
+ Label(label, systemImage: "plus")
}
}
@@ -419,7 +430,7 @@ struct GroupChatInfoView: View {
}
private var memberVerifiedShield: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.baselineOffset(2)
.kerning(-2)
@@ -428,7 +439,7 @@ struct GroupChatInfoView: View {
}
private func memberInfoView(_ groupMember: GMember) -> some View {
- GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
+ GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)
}
@@ -488,11 +499,12 @@ struct GroupChatInfoView: View {
}
}
- private func deleteGroupButton() -> some View {
+ @ViewBuilder private func deleteGroupButton() -> some View {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
- Label("Delete group", systemImage: "trash")
+ Label(label, systemImage: "trash")
.foregroundColor(Color.red)
}
}
@@ -506,20 +518,22 @@ struct GroupChatInfoView: View {
}
}
- private func leaveGroupButton() -> some View {
+ @ViewBuilder private func leaveGroupButton() -> some View {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
- Label("Leave group", systemImage: "rectangle.portrait.and.arrow.right")
+ Label(label, systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(Color.red)
}
}
// TODO reuse this and clearChatAlert with ChatInfoView
private func deleteGroupAlert() -> Alert {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
- title: Text("Delete group?"),
- message: deleteGroupAlertMessage(),
+ title: Text(label),
+ message: deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
@@ -538,10 +552,6 @@ struct GroupChatInfoView: View {
)
}
- private func deleteGroupAlertMessage() -> Text {
- groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
- }
-
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -557,9 +567,15 @@ struct GroupChatInfoView: View {
}
private func leaveGroupAlert() -> Alert {
- Alert(
- title: Text("Leave group?"),
- message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
+ let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "You will stop receiving messages from this group. Chat history will be preserved."
+ : "You will stop receiving messages from this chat. Chat history will be preserved."
+ )
+ return Alert(
+ title: Text(titleLabel),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task {
await leaveGroup(chat.chatInfo.apiId)
@@ -603,9 +619,14 @@ struct GroupChatInfoView: View {
}
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
- Alert(
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Member will be removed from group - this cannot be undone!"
+ : "Member will be removed from chat - this cannot be undone!"
+ )
+ return Alert(
title: Text("Remove member?"),
- message: Text("Member will be removed from group - this cannot be undone!"),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
@@ -625,26 +646,80 @@ struct GroupChatInfoView: View {
}
}
-func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View {
- NavigationLink {
- GroupPreferencesView(
- groupInfo: groupInfo,
- preferences: groupInfo.wrappedValue.fullGroupPreferences,
- currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
- creatingGroup: creatingGroup
- )
- .navigationBarTitle("Group preferences")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- if creatingGroup {
- Text("Set group preferences")
- } else {
- Label("Group preferences", systemImage: "switch.2")
+func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
+ groupInfo.businessChat == nil ? (
+ groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
+ ) : (
+ groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!")
+ )
+}
+
+struct GroupPreferencesButton: View {
+ @Binding var groupInfo: GroupInfo
+ @State var preferences: FullGroupPreferences
+ @State var currentPreferences: FullGroupPreferences
+ var creatingGroup: Bool = false
+
+ private var label: LocalizedStringKey {
+ groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences"
+ }
+
+ var body: some View {
+ NavigationLink {
+ GroupPreferencesView(
+ groupInfo: $groupInfo,
+ preferences: $preferences,
+ currentPreferences: currentPreferences,
+ creatingGroup: creatingGroup,
+ savePreferences: savePreferences
+ )
+ .navigationBarTitle(label)
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ .onDisappear {
+ let saveText = NSLocalizedString(
+ creatingGroup ? "Save" : "Save and notify group members",
+ comment: "alert button"
+ )
+
+ if groupInfo.fullGroupPreferences != preferences {
+ showAlert(
+ title: NSLocalizedString("Save preferences?", comment: "alert title"),
+ buttonTitle: saveText,
+ buttonAction: { savePreferences() },
+ cancelButton: true
+ )
+ }
+ }
+ } label: {
+ if creatingGroup {
+ Text("Set group preferences")
+ } else {
+ Label(label, systemImage: "switch.2")
+ }
}
}
+
+ private func savePreferences() {
+ Task {
+ do {
+ var gp = groupInfo.groupProfile
+ gp.groupPreferences = toGroupPreferences(preferences)
+ let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
+ await MainActor.run {
+ groupInfo = gInfo
+ ChatModel.shared.updateGroup(gInfo)
+ currentPreferences = preferences
+ }
+ } catch {
+ logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
+ }
+ }
+ }
+
}
+
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index ddf3b8e4b9..a18de1b349 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -14,10 +14,15 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo
+ @ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
+ @State private var connectionLoaded: Bool = false
+ @State private var knownContactChat: Chat? = nil
+ @State private var knownContact: Contact? = nil
+ @State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@@ -94,128 +99,149 @@ struct GroupMemberInfoView: View {
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
- if member.memberActive {
- Section {
- if let code = connectionCode { verifyCodeButton(code) }
- if let connStats = connectionStats,
- connStats.ratchetSyncAllowed {
- synchronizeConnectionButton()
- }
- // } else if developerTools {
- // synchronizeConnectionButtonForce()
- // }
- }
- }
+ if connectionLoaded {
- if let contactLink = member.contactLink {
- Section {
- SimpleXLinkQRCode(uri: contactLink)
- Button {
- showShareSheet(items: [simplexChatLink(contactLink)])
- } label: {
- Label("Share address", systemImage: "square.and.arrow.up")
+ if member.memberActive {
+ Section {
+ if let code = connectionCode { verifyCodeButton(code) }
+ if let connStats = connectionStats,
+ connStats.ratchetSyncAllowed {
+ synchronizeConnectionButton()
+ }
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
}
- if let contactId = member.memberContactId {
- if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
+ }
+
+ if let contactLink = member.contactLink {
+ Section {
+ SimpleXLinkQRCode(uri: contactLink)
+ Button {
+ showShareSheet(items: [simplexChatLink(contactLink)])
+ } label: {
+ Label("Share address", systemImage: "square.and.arrow.up")
+ }
+ if member.memberContactId != nil {
+ if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
+ connectViaAddressButton(contactLink)
+ }
+ } else {
connectViaAddressButton(contactLink)
}
- } else {
- connectViaAddressButton(contactLink)
+ } header: {
+ Text("Address")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
+ .foregroundColor(theme.colors.secondary)
}
- } header: {
- Text("Address")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
- .foregroundColor(theme.colors.secondary)
}
- }
- Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
- infoRow("Group", groupInfo.displayName)
+ Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
+ infoRow(label, groupInfo.displayName)
- if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
- Picker("Change role", selection: $newRole) {
- ForEach(roles) { role in
- Text(role.text)
+ 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)
}
- .frame(height: 36)
- } else {
- infoRow("Role", member.memberRole.text)
}
- }
- if let connStats = connectionStats {
- Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
- // TODO network connection status
- Button("Change receiving address") {
- alert = .switchAddressAlert
- }
- .disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
- || connStats.ratchetSyncSendProhibited
- )
- if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
- Button("Abort changing address") {
- alert = .abortSwitchAddressAlert
+ if let connStats = connectionStats {
+ Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
+ // TODO network connection status
+ Button("Change receiving address") {
+ alert = .switchAddressAlert
}
.disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
+ if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
+ Button("Abort changing address") {
+ alert = .abortSwitchAddressAlert
+ }
+ .disabled(
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ || connStats.ratchetSyncSendProhibited
+ )
+ }
+ smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
+ smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
- }
- if groupInfo.membership.memberRole >= .admin {
- adminDestructiveSection(member)
- } else {
- nonAdminBlockSection(member)
- }
+ if groupInfo.membership.memberRole >= .admin {
+ adminDestructiveSection(member)
+ } else {
+ nonAdminBlockSection(member)
+ }
- if developerTools {
- Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
- infoRow("Local name", member.localDisplayName)
- infoRow("Database ID", "\(member.groupMemberId)")
- if let conn = member.activeConn {
- let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
- infoRow("Connection", connLevelDesc)
- }
- Button ("Debug delivery") {
- Task {
- do {
- let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
- await MainActor.run { alert = .queueInfo(info: info) }
- } catch let e {
- logger.error("apiContactQueueInfo error: \(responseError(e))")
- let a = getErrorAlert(e, "Error")
- await MainActor.run { alert = .error(title: a.title, error: a.message) }
+ if developerTools {
+ Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
+ infoRow("Local name", member.localDisplayName)
+ infoRow("Database ID", "\(member.groupMemberId)")
+ if let conn = member.activeConn {
+ let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
+ infoRow("Connection", connLevelDesc)
+ }
+ Button ("Debug delivery") {
+ Task {
+ do {
+ let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
+ await MainActor.run { alert = .queueInfo(info: info) }
+ } catch let e {
+ logger.error("apiContactQueueInfo error: \(responseError(e))")
+ let a = getErrorAlert(e, "Error")
+ await MainActor.run { alert = .error(title: a.title, error: a.message) }
+ }
}
}
}
}
+
}
}
.navigationBarHidden(true)
- .onAppear {
+ .task {
if #unavailable(iOS 16) {
// this condition prevents re-setting picker
if !justOpened { return }
}
justOpened = false
- DispatchQueue.main.async {
- newRole = member.memberRole
- do {
- let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
- let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
+ newRole = member.memberRole
+ do {
+ let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
+ let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
+ await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
+ connectionLoaded = true
+ }
+ } catch let error {
+ await MainActor.run {
+ connectionLoaded = true
+ }
+ logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ }
+ if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
+ knownContactChat = contactChat
+ knownContact = contact
+ do {
+ let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
+ await MainActor.run {
+ knownContactConnectionStats = stats
+ }
} catch let error {
- logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ logger.error("apiContactInfo error: \(responseError(error))")
}
}
}
@@ -251,6 +277,11 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2)
}
}
+ .onChange(of: chat.chatInfo) { c in
+ if case let .group(gI) = chat.chatInfo {
+ groupInfo = gI
+ }
+ }
.modifier(ThemedBackground(grouped: true))
}
@@ -258,10 +289,10 @@ struct GroupMemberInfoView: View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
- if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
+ if let chat = knownContactChat, let contact = knownContact {
knownDirectChatButton(chat, width: buttonWidth)
- AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
- VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
+ VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
@@ -296,10 +327,15 @@ struct GroupMemberInfoView: View {
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Direct messages between members are prohibited."
+ : "Direct messages between members are prohibited in this chat."
+ )
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
- message: "Direct messages between members are prohibited in this group."
+ message: messageLabel
),
id: "can't message member, direct messages prohibited"
))
@@ -345,25 +381,49 @@ struct GroupMemberInfoView: View {
func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
- progressIndicator = true
- Task {
- do {
- let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
- await MainActor.run {
- progressIndicator = false
- chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
- ItemsModel.shared.loadOpenChat(memberContact.id) {
- dismissAllSheets(animated: true)
+ if let connStats = connectionStats {
+ if connStats.ratchetSyncState == .ok {
+ progressIndicator = true
+ Task {
+ do {
+ let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
+ await MainActor.run {
+ progressIndicator = false
+ chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
+ ItemsModel.shared.loadOpenChat(memberContact.id) {
+ dismissAllSheets(animated: true)
+ }
+ NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
+ }
+ } catch let error {
+ logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
+ let a = getErrorAlert(error, "Error creating member contact")
+ await MainActor.run {
+ progressIndicator = false
+ alert = .error(title: a.title, error: a.message)
+ }
}
- NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
- }
- } catch let error {
- logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
- let a = getErrorAlert(error, "Error creating member contact")
- await MainActor.run {
- progressIndicator = false
- alert = .error(title: a.title, error: a.message)
}
+ } else if connStats.ratchetSyncAllowed {
+ alert = .someAlert(alert: SomeAlert(
+ alert: Alert(
+ title: Text("Fix connection?"),
+ message: Text("Connection requires encryption renegotiation."),
+ primaryButton: .default(Text("Fix")) {
+ syncMemberConnection(force: false)
+ },
+ secondaryButton: .cancel()
+ ),
+ id: "can't message member, fix connection"
+ ))
+ } else {
+ alert = .someAlert(alert: SomeAlert(
+ alert: mkAlert(
+ title: "Can't message member",
+ message: "Encryption renegotiation in progress."
+ ),
+ id: "can't message contact, encryption renegotiation in progress"
+ ))
}
}
}
@@ -379,7 +439,7 @@ struct GroupMemberInfoView: View {
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
- + Text(" ")
+ + textSpace
+ Text(mem.displayName)
.font(.largeTitle)
)
@@ -528,9 +588,14 @@ struct GroupMemberInfoView: View {
}
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
- Alert(
+ let label: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "Member will be removed from group - this cannot be undone!"
+ : "Member will be removed from chat - this cannot be undone!"
+ )
+ return Alert(
title: Text("Remove member?"),
- message: Text("Member will be removed from group - this cannot be undone!"),
+ message: Text(label),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
@@ -553,7 +618,15 @@ struct GroupMemberInfoView: View {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Change member role?"),
- message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
+ message: (
+ mem.memberCurrent
+ ? (
+ groupInfo.businessChat == nil
+ ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
+ : Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
+ )
+ : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
+ ),
primaryButton: .default(Text("Change")) {
Task {
do {
@@ -561,7 +634,7 @@ struct GroupMemberInfoView: View {
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
-
+
} catch let error {
newRole = mem.memberRole
logger.error("apiMemberRole error: \(responseError(error))")
@@ -730,6 +803,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
+ chat: Chat.sampleData,
groupMember: GMember.sampleData
)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
index 2b0d05375b..9ef53258aa 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -20,9 +20,10 @@ struct GroupPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
- @State var preferences: FullGroupPreferences
- @State var currentPreferences: FullGroupPreferences
+ @Binding var preferences: FullGroupPreferences
+ var currentPreferences: FullGroupPreferences
let creatingGroup: Bool
+ let savePreferences: () -> Void
@State private var showSaveDialogue = false
var body: some View {
@@ -38,7 +39,7 @@ struct GroupPreferencesView: View {
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.history, $preferences.history.enable)
- if groupInfo.canEdit {
+ if groupInfo.isOwner {
Section {
Button("Reset") { preferences = currentPreferences }
Button(saveText) { savePreferences() }
@@ -68,7 +69,10 @@ struct GroupPreferencesView: View {
savePreferences()
dismiss()
}
- Button("Exit without saving") { dismiss() }
+ Button("Exit without saving") {
+ preferences = currentPreferences
+ dismiss()
+ }
}
}
@@ -77,7 +81,7 @@ struct GroupPreferencesView: View {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
- if groupInfo.canEdit {
+ if groupInfo.isOwner {
let enable = Binding(
get: { enableFeature.wrappedValue == .on },
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
@@ -123,7 +127,7 @@ struct GroupPreferencesView: View {
}
}
} footer: {
- Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
+ Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
.foregroundColor(theme.colors.secondary)
}
.onChange(of: enableFeature.wrappedValue) { enabled in
@@ -132,32 +136,16 @@ struct GroupPreferencesView: View {
}
}
}
-
- private func savePreferences() {
- Task {
- do {
- var gp = groupInfo.groupProfile
- gp.groupPreferences = toGroupPreferences(preferences)
- let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
- await MainActor.run {
- groupInfo = gInfo
- chatModel.updateGroup(gInfo)
- currentPreferences = preferences
- }
- } catch {
- logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
- }
- }
- }
}
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
- preferences: FullGroupPreferences.sampleData,
+ preferences: Binding.constant(FullGroupPreferences.sampleData),
currentPreferences: FullGroupPreferences.sampleData,
- creatingGroup: false
+ creatingGroup: false,
+ savePreferences: {}
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 18cc3f4d80..1617edd11f 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -110,10 +110,13 @@ struct GroupProfileView: View {
}
}
.onChange(of: chosenImage) { image in
- if let image = image {
- groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
- } else {
- groupProfile.image = nil
+ Task {
+ let resized: String? = if let image {
+ await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
+ } else {
+ nil
+ }
+ await MainActor.run { groupProfile.image = resized }
}
}
.onAppear {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
index 9a9002f9dc..8dfc32f6ea 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
@@ -23,7 +23,7 @@ struct GroupWelcomeView: View {
var body: some View {
VStack {
- if groupInfo.canEdit {
+ if groupInfo.isOwner && groupInfo.businessChat == nil {
editorView()
.modifier(BackButton(disabled: Binding.constant(false)) {
if welcomeTextUnchanged() {
diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift
index 4ce6a1a065..e33adcef58 100644
--- a/apps/ios/Shared/Views/Chat/ReverseList.swift
+++ b/apps/ios/Shared/Views/Chat/ReverseList.swift
@@ -8,15 +8,16 @@
import SwiftUI
import Combine
+import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
-struct ReverseList: UIViewControllerRepresentable {
- let items: Array-
+struct ReverseList: UIViewControllerRepresentable {
+ let items: Array
- @Binding var scrollState: ReverseListScrollModel
- .State
+ @Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item
- let content: (Item) -> Content
+ let content: (ChatItem) -> Content
let loadPage: () -> Void
@@ -25,7 +26,9 @@ struct ReverseList: UIV
}
func updateUIViewController(_ controller: Controller, context: Context) {
+ controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
+ controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
@@ -42,9 +45,10 @@ struct ReverseList: UIV
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
- private let representer: ReverseList
- private var dataSource: UITableViewDiffableDataSource!
+ var representer: ReverseList
+ private var dataSource: UITableViewDiffableDataSource!
private var itemCount: Int = 0
+ private let updateFloatingButtons = PassthroughSubject()
private var bag = Set()
init(representer: ReverseList) {
@@ -71,10 +75,10 @@ struct ReverseList: UIV
}
// 3. Configure data source
- self.dataSource = UITableViewDiffableDataSource(
+ self.dataSource = UITableViewDiffableDataSource(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
- if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
+ if indexPath.item > self.itemCount - 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
@@ -103,6 +107,15 @@ struct ReverseList: UIV
name: notificationName,
object: nil
)
+
+ updateFloatingButtons
+ .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
+ .sink {
+ if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
+ ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
+ }
+ }
+ .store(in: &bag)
}
@available(*, unavailable)
@@ -171,8 +184,8 @@ struct ReverseList: UIV
Task { representer.scrollState = .atDestination }
}
- func update(items: Array
- ) {
- var snapshot = NSDiffableDataSourceSnapshot()
+ func update(items: [ChatItem]) {
+ var snapshot = NSDiffableDataSourceSnapshot()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
@@ -180,7 +193,50 @@ struct ReverseList: UIV
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
+ // Sets content offset on initial load
+ if itemCount == 0 {
+ tableView.setContentOffset(
+ CGPoint(x: 0, y: -InvertedTableView.inset),
+ animated: false
+ )
+ }
itemCount = items.count
+ updateFloatingButtons.send()
+ }
+
+ override func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ updateFloatingButtons.send()
+ }
+
+ func getListState() -> ListState? {
+ if let visibleRows = tableView.indexPathsForVisibleRows,
+ visibleRows.last?.item ?? 0 < representer.items.count {
+ let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
+ let topItemDate: Date? =
+ if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
+ representer.items[lastVisible.item].meta.itemTs
+ } else {
+ nil
+ }
+ let bottomItemId: ChatItem.ID? =
+ if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
+ representer.items[firstVisible.item].id
+ } else {
+ nil
+ }
+ return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
+ }
+ return nil
+ }
+
+ private func isVisible(indexPath: IndexPath) -> Bool {
+ if let relativeFrame = tableView.superview?.convert(
+ tableView.rectForRow(at: indexPath),
+ from: tableView
+ ) {
+ relativeFrame.maxY > InvertedTableView.inset &&
+ relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
+ } else { false }
}
}
@@ -224,13 +280,19 @@ struct ReverseList: UIV
}
}
+typealias ListState = (
+ scrollOffset: Double,
+ topItemDate: Date?,
+ bottomItemId: ChatItem.ID?
+)
+
/// Manages ``ReverseList`` scrolling
-class ReverseListScrollModel: ObservableObject {
+class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
- case item(Item.ID)
+ case item(ChatItem.ID)
case bottom
}
@@ -248,7 +310,7 @@ class ReverseListScrollModel: ObservableObject {
state = .scrollingTo(.bottom)
}
- func scrollToItem(id: Item.ID) {
+ func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
}
diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
index 87bc73a60e..7b185d8211 100644
--- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
+++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
@@ -32,13 +32,17 @@ struct SelectedItemsBottomToolbar: View {
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
+ var forwardItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
- @State var allButtonsDisabled = false
+ @State var forwardEnabled: Bool = false
+
+ @State var deleteCountProhibited = false
+ @State var forwardCountProhibited = false
var body: some View {
VStack(spacing: 0) {
@@ -50,10 +54,11 @@ struct SelectedItemsBottomToolbar: View {
} label: {
Image(systemName: "trash")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
+ .foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
}
- .disabled(!deleteEnabled || allButtonsDisabled)
+ .disabled(!deleteEnabled || deleteCountProhibited)
Spacer()
Button {
@@ -61,24 +66,24 @@ struct SelectedItemsBottomToolbar: View {
} label: {
Image(systemName: "flag")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
+ .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
}
- .disabled(!moderateEnabled || allButtonsDisabled)
+ .disabled(!moderateEnabled || deleteCountProhibited)
.opacity(canModerate ? 1 : 0)
-
Spacer()
Button {
- //shareItems()
+ forwardItems()
} label: {
- Image(systemName: "square.and.arrow.up")
+ Image(systemName: "arrowshape.turn.up.forward")
.resizable()
+ .scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
+ .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
}
- .disabled(allButtonsDisabled)
- .opacity(0)
+ .disabled(!forwardEnabled || forwardCountProhibited)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
@@ -101,20 +106,22 @@ struct SelectedItemsBottomToolbar: View {
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set?) {
let count = selectedItems?.count ?? 0
- allButtonsDisabled = count == 0 || count > 20
+ deleteCountProhibited = count == 0 || count > 200
+ forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
- (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
+ (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
- var (de, dee, me, onlyOwnGroupItems, sel) = r
+ var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
+ fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
- return (de, dee, me, onlyOwnGroupItems, sel)
+ return (de, dee, me, onlyOwnGroupItems, fe, sel)
} else {
return r
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
index c84cdb0b97..7abab33177 100644
--- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
@@ -10,7 +10,7 @@ import SwiftUI
struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel
- @Binding var showSettings: Bool
+ let dismissSettingsSheet: DismissAction
var body: some View {
ScrollView { chatHelp() }
@@ -23,7 +23,7 @@ struct ChatHelp: View {
VStack(alignment: .leading, spacing: 0) {
Text("To ask any questions and to receive updates:")
Button("connect to SimpleX Chat developers.") {
- showSettings = false
+ dismissSettingsSheet()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
}
@@ -42,7 +42,8 @@ struct ChatHelp: View {
Text("above, then choose:")
}
- Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
+ Text("**Create 1-time link**: to create and share a new invitation link.")
+ Text("**Scan / Paste link**: to connect via a link you received.")
Text("**Create group**: to create a new group.")
}
.padding(.top, 24)
@@ -61,8 +62,9 @@ struct ChatHelp: View {
}
struct ChatHelp_Previews: PreviewProvider {
+ @Environment(\.dismiss) static var mockDismiss
+
static var previews: some View {
- @State var showSettings = false
- return ChatHelp(showSettings: $showSettings)
+ ChatHelp(dismissSettingsSheet: mockDismiss)
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index c8424ef332..6c5dad1f74 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -355,6 +355,7 @@ struct ChatListNavLink: View {
.tint(.red)
}
.frame(height: dynamicRowHeight)
+ .contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -392,6 +393,7 @@ struct ChatListNavLink: View {
}
}
}
+ .contentShape(Rectangle())
.onTapGesture {
showContactConnectionInfo = true
}
@@ -402,8 +404,9 @@ struct ChatListNavLink: View {
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
- Alert(
- title: Text("Delete group?"),
+ let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
+ return Alert(
+ title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task { await deleteChat(chat) }
@@ -412,10 +415,6 @@ struct ChatListNavLink: View {
)
}
- private func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
- groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
- }
-
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -439,9 +438,15 @@ struct ChatListNavLink: View {
}
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
- Alert(
- title: Text("Leave group?"),
- message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
+ let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
+ let messageLabel: LocalizedStringKey = (
+ groupInfo.businessChat == nil
+ ? "You will stop receiving messages from this group. Chat history will be preserved."
+ : "You will stop receiving messages from this chat. Chat history will be preserved."
+ )
+ return Alert(
+ title: Text(titleLabel),
+ message: Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task { await leaveGroup(groupInfo.groupId) }
},
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 8c9d090890..b18e9295b9 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -9,22 +9,119 @@
import SwiftUI
import SimpleXChat
+enum UserPickerSheet: Identifiable {
+ case address
+ case chatPreferences
+ case chatProfiles
+ case currentProfile
+ case useFromDesktop
+ case settings
+
+ var id: Self { self }
+
+ var navigationTitle: LocalizedStringKey {
+ switch self {
+ case .address: "SimpleX address"
+ case .chatPreferences: "Your preferences"
+ case .chatProfiles: "Your chat profiles"
+ case .currentProfile: "Your current profile"
+ case .useFromDesktop: "Connect to desktop"
+ case .settings: "Your settings"
+ }
+ }
+}
+
+class SaveableSettings: ObservableObject {
+ @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
+}
+
+struct ServerSettings {
+ public var currUserServers: [UserOperatorServers]
+ public var userServers: [UserOperatorServers]
+ public var serverErrors: [UserServersError]
+}
+
+struct UserPickerSheetView: View {
+ let sheet: UserPickerSheet
+ @EnvironmentObject var chatModel: ChatModel
+ @StateObject private var ss = SaveableSettings()
+
+ @State private var loaded = false
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ if loaded, let currentUser = chatModel.currentUser {
+ switch sheet {
+ case .address:
+ UserAddressView(shareViaProfile: currentUser.addressShared)
+ case .chatPreferences:
+ PreferencesView(
+ profile: currentUser.profile,
+ preferences: currentUser.fullPreferences,
+ currentPreferences: currentUser.fullPreferences
+ )
+ case .chatProfiles:
+ UserProfilesView()
+ case .currentProfile:
+ UserProfile()
+ case .useFromDesktop:
+ ConnectDesktopView()
+ case .settings:
+ SettingsView()
+ }
+ }
+ Color.clear // Required for list background to be rendered during loading
+ }
+ .navigationTitle(sheet.navigationTitle)
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .overlay {
+ if let la = chatModel.laRequest {
+ LocalAuthView(authRequest: la)
+ }
+ }
+ .task {
+ withAnimation(
+ .easeOut(duration: 0.1),
+ { loaded = true }
+ )
+ }
+ .onDisappear {
+ if serversCanBeSaved(
+ ss.servers.currUserServers,
+ ss.servers.userServers,
+ ss.servers.serverErrors
+ ) {
+ showAlert(
+ title: NSLocalizedString("Save servers?", comment: "alert title"),
+ buttonTitle: NSLocalizedString("Save", comment: "alert button"),
+ buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
+ cancelButton: true
+ )
+ }
+ }
+ .environmentObject(ss)
+ }
+}
+
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
+ @Binding var activeUserPickerSheet: UserPickerSheet?
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
- @State private var userPickerVisible = false
- @State private var showConnectDesktop = false
@State private var scrollToSearchBar = false
+ @State private var userPickerShown: Bool = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
+ @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
@@ -46,21 +143,23 @@ struct ChatListView: View {
),
destination: chatView
) { chatListView }
- if userPickerVisible {
- Rectangle().fill(.white.opacity(0.001)).onTapGesture {
- withAnimation {
- userPickerVisible.toggle()
- }
+ }
+ .modifier(
+ Sheet(isPresented: $userPickerShown) {
+ UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
+ }
+ )
+ .appSheet(
+ item: $activeUserPickerSheet,
+ onDismiss: { chatModel.laRequest = nil },
+ content: { UserPickerSheetView(sheet: $0) }
+ )
+ .onChange(of: activeUserPickerSheet) {
+ if $0 != nil {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ userPickerShown = false
}
}
- UserPicker(
- showSettings: $showSettings,
- showConnectDesktop: $showConnectDesktop,
- userPickerVisible: $userPickerVisible
- )
- }
- .sheet(isPresented: $showConnectDesktop) {
- ConnectDesktopView()
}
}
@@ -73,7 +172,7 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .onDisappear() { withAnimation { userPickerVisible = false } }
+ .onDisappear() { activeUserPickerSheet = nil }
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -164,7 +263,7 @@ struct ChatListView: View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
- .padding(.trailing, 4)
+ .padding([.top, .trailing], 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
@@ -173,13 +272,7 @@ struct ChatListView: View {
}
}
.onTapGesture {
- if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
- withAnimation {
- userPickerVisible.toggle()
- }
- } else {
- showSettings = true
- }
+ userPickerShown = true
}
}
@@ -211,20 +304,45 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
+ if #available(iOS 16.0, *) {
+ ForEach(cs, id: \.viewId) { chat in
+ ChatListNavLink(chat: chat)
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .padding(.trailing, -16)
+ .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
+ .listRowBackground(Color.clear)
+ }
+ .offset(x: -8)
+ } else {
+ ForEach(cs, id: \.viewId) { chat in
+ VStack(spacing: .zero) {
+ Divider()
+ .padding(.leading, 16)
+ ChatListNavLink(chat: chat)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ }
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets())
+ .background { theme.colors.background } // Hides default list selection colour
+ .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
+ }
+ }
if !oneHandUICardShown {
OneHandUICard()
+ .padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
- ForEach(cs, id: \.viewId) { chat in
- ChatListNavLink(chat: chat)
+ if !addressCreationCardShown {
+ AddressCreationCard()
+ .padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .padding(.trailing, -16)
- .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
+ .listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
- .offset(x: -8)
}
.listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in
@@ -252,7 +370,7 @@ struct ChatListView: View {
}
}
- private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
+ private func unreadBadge(size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
@@ -324,7 +442,7 @@ struct ChatListView: View {
struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false
- @State private var timer: Timer? = nil
+ @State private var task: Task?
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@@ -343,10 +461,10 @@ struct SubsStatusIndicator: View {
}
.disabled(ChatModel.shared.chatRunning != true)
.onAppear {
- startTimer()
+ startTask()
}
.onDisappear {
- stopTimer()
+ stopTask()
}
.appSheet(isPresented: $showServersSummary) {
ServersSummaryView()
@@ -354,25 +472,28 @@ struct SubsStatusIndicator: View {
}
}
- private func startTimer() {
- timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
- if AppChatState.shared.value == .active {
- getSubsTotal()
+ private func startTask() {
+ task = Task {
+ while !Task.isCancelled {
+ if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true {
+ do {
+ let (subs, hasSess) = try await getAgentSubsTotal()
+ await MainActor.run {
+ self.subs = subs
+ self.hasSess = hasSess
+ }
+ } catch let error {
+ logger.error("getSubsTotal error: \(responseError(error))")
+ }
+ }
+ try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
}
}
}
- func stopTimer() {
- timer?.invalidate()
- timer = nil
- }
-
- private func getSubsTotal() {
- do {
- (subs, hasSess) = try getAgentSubsTotal()
- } catch let error {
- logger.error("getSubsTotal error: \(responseError(error))")
- }
+ func stopTask() {
+ task?.cancel()
+ task = nil
}
}
@@ -496,27 +617,29 @@ func chatStoppedIcon() -> some View {
}
struct ChatListView_Previews: PreviewProvider {
+ @State static var userPickerSheet: UserPickerSheet? = .none
+
static var previews: some View {
let chatModel = ChatModel()
- chatModel.chats = [
- Chat(
+ chatModel.updateChats([
+ ChatData(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
),
- Chat(
+ ChatData(
chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "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.")]
),
- Chat(
+ ChatData(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
)
- ]
+ ])
return Group {
- ChatListView(showSettings: Binding.constant(false))
+ ChatListView(activeUserPickerSheet: $userPickerSheet)
.environmentObject(chatModel)
- ChatListView(showSettings: Binding.constant(false))
+ ChatListView(activeUserPickerSheet: $userPickerSheet)
.environmentObject(ChatModel())
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 9e6d3005b6..13701a40a2 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -35,11 +35,16 @@ struct ChatPreviewView: View {
}
.padding(.leading, 4)
+ let chatTs = if let cItem {
+ cItem.meta.itemTs
+ } else {
+ chat.chatInfo.chatTs
+ }
VStack(spacing: 0) {
HStack(alignment: .top) {
chatPreviewTitle()
Spacer()
- (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
+ (formatTimestampText(chatTs))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(theme.colors.secondary)
@@ -167,7 +172,7 @@ struct ChatPreviewView: View {
}
private var verifiedIcon: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
@@ -227,12 +232,12 @@ struct ChatPreviewView: View {
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
- Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
+ Text(Image(systemName: s)).foregroundColor(color) + textSpace
}
func attachment() -> Text {
switch draft.preview {
- case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
+ case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + textSpace
case .mediaPreviews: return image("photo")
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
default: return Text("")
@@ -302,7 +307,7 @@ struct ChatPreviewView: View {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
- Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
+ Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
@@ -323,13 +328,13 @@ struct ChatPreviewView: View {
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
- CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
+ CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
+ .environmentObject(ReverseListScrollModel())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
- CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
- .environmentObject(ReverseListScrollModel())
+ CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
+ .environmentObject(ReverseListScrollModel())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -362,11 +367,11 @@ struct ChatPreviewView: View {
case .sndErrorAuth, .sndError:
return Text(Image(systemName: "multiply"))
.font(.caption)
- .foregroundColor(.red) + Text(" ")
+ .foregroundColor(.red) + textSpace
case .sndWarning:
return Text(Image(systemName: "exclamationmark.triangle.fill"))
.font(.caption)
- .foregroundColor(.orange) + Text(" ")
+ .foregroundColor(.orange) + textSpace
default: return Text("")
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
index 3c7bf97af9..f5156d86b8 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
@@ -16,7 +16,6 @@ struct ContactConnectionView: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
- @State private var showContactConnectionInfo = false
var body: some View {
if case let .contactConnection(conn) = chat.chatInfo {
@@ -32,7 +31,6 @@ struct ContactConnectionView: View {
.scaledToFill()
.frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
- .onTapGesture { showContactConnectionInfo = true }
}
.frame(width: 63, height: 63)
.padding(.leading, 4)
@@ -72,9 +70,6 @@ struct ContactConnectionView: View {
Spacer()
}
.frame(maxHeight: .infinity)
- .appSheet(isPresented: $showContactConnectionInfo) {
- ContactConnectionInfo(contactConnection: contactConnection)
- }
}
}
}
diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
index 636d165114..059f24cc82 100644
--- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
+++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
@@ -32,7 +32,6 @@ struct OneHandUICard: View {
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
- .padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
index 477a78e36d..aa802c1af9 100644
--- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
+++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
@@ -407,12 +407,18 @@ struct ServersSummaryView: View {
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let onionHosts = networkUseOnionHostsGroupDefault.get()
- let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
+ let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
+ online: m.networkInfo.online,
+ usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
+ subs: subs,
+ hasSess: hasSess,
+ primaryColor: theme.colors.primary
+ )
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
@@ -425,26 +431,32 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let onionHosts = networkUseOnionHostsGroupDefault.get()
- let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
+ let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
+ online: m.networkInfo.online,
+ usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
+ subs: subs,
+ hasSess: hasSess,
+ primaryColor: theme.colors.primary
+ )
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
-func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
+func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
: (n * 4).rounded() / 4
}
- let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
+ let activeColor: Color = usesProxy ? .indigo : primaryColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
@@ -479,15 +491,6 @@ struct SMPServerSummaryView: View {
Section("Server address") {
Text(summary.smpServer)
.textSelection(.enabled)
- if summary.known == true {
- NavigationLink {
- ProtocolServersView(serverProtocol: .smp)
- .navigationTitle("Your SMP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Open server settings")
- }
- }
}
if let stats = summary.stats {
@@ -584,7 +587,7 @@ struct SMPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
}
}
}
@@ -667,15 +670,6 @@ struct XFTPServerSummaryView: View {
Section("Server address") {
Text(summary.xftpServer)
.textSelection(.enabled)
- if summary.known == true {
- NavigationLink {
- ProtocolServersView(serverProtocol: .xftp)
- .navigationTitle("Your XFTP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Open server settings")
- }
- }
}
if let stats = summary.stats {
@@ -709,7 +703,7 @@ struct XFTPStatsView: View {
} header: {
Text("Statistics")
} footer: {
- Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
+ Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
}
}
}
diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift
index eeb7bf14f4..dbe10ad997 100644
--- a/apps/ios/Shared/Views/ChatList/UserPicker.swift
+++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift
@@ -8,176 +8,281 @@ import SimpleXChat
struct UserPicker: View {
@EnvironmentObject var m: ChatModel
- @Environment(\.scenePhase) var scenePhase
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
- @Binding var showConnectDesktop: Bool
- @Binding var userPickerVisible: Bool
- @State var scrollViewContentSize: CGSize = .zero
- @State var disableScrolling: Bool = true
- private let menuButtonHeight: CGFloat = 68
- @State var chatViewNameWidth: CGFloat = 0
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ @Environment(\.scenePhase) private var scenePhase: ScenePhase
+ @Environment(\.colorScheme) private var colorScheme: ColorScheme
+ @Binding var userPickerShown: Bool
+ @Binding var activeSheet: UserPickerSheet?
+ @State private var currentUser: Int64?
+ @State private var switchingProfile = false
+ @State private var frameWidth: CGFloat = 0
+ @State private var resetScroll = ResetScrollAction()
+ // Inset grouped list dimensions
+ private let imageSize: CGFloat = 44
+ private let rowPadding: CGFloat = 16
+ private let rowVerticalPadding: CGFloat = 11
+ private let sectionSpacing: CGFloat = 35
+ private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
+ private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
var body: some View {
- VStack {
- Spacer().frame(height: 1)
+ let otherUsers: [UserInfo] = m.users
+ .filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
+ .sorted(using: KeyPathComparator(\.user.activeOrder, order: .reverse))
+ let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
+ let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
+ let stopped = m.chatRunning != true
+ VStack(spacing: sectionSpacing) {
+ if let user = m.currentUser {
+ StickyScrollView(resetScroll: $resetScroll) {
+ HStack(spacing: rowPadding) {
+ HStack {
+ ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
+ .padding(.trailing, 6)
+ profileName(user).lineLimit(1)
+ }
+ .padding(rowPadding)
+ .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
+ .modifier(ListRow { activeSheet = .currentProfile })
+ .clipShape(sectionShape)
+ .disabled(stopped)
+ .opacity(stopped ? 0.4 : 1)
+ ForEach(otherUsers) { u in
+ userView(u, size: imageSize)
+ .frame(maxWidth: sectionWidth * 0.618)
+ .fixedSize()
+ .disabled(stopped)
+ .opacity(stopped ? 0.4 : 1)
+ }
+ }
+ .padding(.horizontal, sectionHorizontalPadding)
+ }
+ .frame(height: 2 * rowPadding + imageSize)
+ .padding(.top, sectionSpacing)
+ .overlay(DetermineWidth())
+ .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
+ }
VStack(spacing: 0) {
- ScrollView {
- ScrollViewReader { sp in
- let users = m.users
- .filter({ u in u.user.activeUser || !u.user.hidden })
- .sorted { u, _ in u.user.activeUser }
- VStack(spacing: 0) {
- ForEach(users) { u in
- userView(u)
- Divider()
- if u.user.activeUser { Divider() }
+ openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped)
+ openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped)
+ openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped)
+ openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped)
+ ZStack(alignment: .trailing) {
+ openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
+ Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.monochrome)
+ .foregroundColor(theme.colors.secondary)
+ .frame(maxWidth: 20, maxHeight: .infinity)
+ .padding(.horizontal, rowPadding)
+ .background(Color(.systemBackground).opacity(0.01))
+ .onTapGesture {
+ if (colorScheme == .light) {
+ ThemeManager.applyTheme(systemDarkThemeDefault.get())
+ } else {
+ ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
}
}
- .overlay {
- GeometryReader { geo -> Color in
- DispatchQueue.main.async {
- scrollViewContentSize = geo.size
- let scenes = UIApplication.shared.connectedScenes
- if let windowScene = scenes.first as? UIWindowScene {
- let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame
- disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height
- }
- }
- return Color.clear
- }
+ .onLongPressGesture {
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
- .onChange(of: userPickerVisible) { visible in
- if visible, let u = users.first {
- sp.scrollTo(u.id)
- }
- }
- }
- }
- .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
- .frame(maxHeight: scrollViewContentSize.height)
-
- menuButton("Use from desktop", icon: "desktopcomputer") {
- showConnectDesktop = true
- withAnimation {
- userPickerVisible.toggle()
- }
- }
- Divider()
- menuButton("Settings", icon: "gearshape") {
- showSettings = true
- withAnimation {
- userPickerVisible.toggle()
- }
}
}
+ .clipShape(sectionShape)
+ .padding(.horizontal, sectionHorizontalPadding)
+ .padding(.bottom, sectionSpacing)
}
- .clipShape(RoundedRectangle(cornerRadius: 16))
- .background(
- Rectangle()
- .fill(theme.colors.surface)
- .cornerRadius(16)
- .shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
- )
- .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
- .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
- .padding(8)
- .opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear {
- do {
- // This check prevents the call of listUsers after the app is suspended, and the database is closed.
- if case .active = scenePhase {
- m.users = try listUsers()
- }
- } catch let error {
- logger.error("Error loading users \(responseError(error))")
- }
- }
- }
-
- private func userView(_ u: UserInfo) -> some View {
- let user = u.user
- return Button(action: {
- if user.activeUser {
- showSettings = true
- withAnimation {
- userPickerVisible.toggle()
- }
- } else {
+ // This check prevents the call of listUsers after the app is suspended, and the database is closed.
+ if case .active = scenePhase {
+ currentUser = m.currentUser?.userId
Task {
do {
- try await changeActiveUserAsync_(user.userId, viewPwd: nil)
- await MainActor.run { userPickerVisible = false }
- } catch {
+ let users = try await listUsersAsync()
await MainActor.run {
- AlertManager.shared.showAlertMsg(
- title: "Error switching profile!",
- message: "Error: \(responseError(error))"
- )
+ m.users = users
+ currentUser = m.currentUser?.userId
}
+ } catch {
+ logger.error("Error loading users \(responseError(error))")
}
}
}
- }, label: {
- HStack(spacing: 0) {
- ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill))
- .padding(.trailing, 12)
- Text(user.chatViewName)
- .fontWeight(user.activeUser ? .medium : .regular)
- .foregroundColor(theme.colors.onBackground)
- .overlay(DetermineWidth())
- Spacer()
- if user.activeUser {
- Image(systemName: "checkmark")
- } else if u.unreadCount > 0 {
- unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
- } else if !user.showNtfs {
- Image(systemName: "speaker.slash")
+ }
+ .onChange(of: userPickerShown) {
+ if !$0 { resetScroll() }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .disabled(switchingProfile)
+ }
+
+ private func userView(_ u: UserInfo, size: CGFloat) -> some View {
+ HStack {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
+ if (u.unreadCount > 0) {
+ UnreadBadge(userInfo: u).offset(x: 4, y: -4)
}
}
- .padding(.trailing)
- .padding([.leading, .vertical], 12)
- })
- .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
- }
-
- private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- HStack(spacing: 0) {
- Text(title)
- .overlay(DetermineWidth())
- Spacer()
- Image(systemName: icon)
- .symbolRenderingMode(.monochrome)
- .foregroundColor(theme.colors.secondary)
- }
- .padding(.horizontal)
- .padding(.vertical, 22)
- .frame(height: menuButtonHeight)
+ .padding(.trailing, 6)
+ Text(u.user.displayName).font(.title2).lineLimit(1)
+ }
+ .padding(rowPadding)
+ .modifier(ListRow {
+ switchingProfile = true
+ Task {
+ do {
+ try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
+ await MainActor.run {
+ switchingProfile = false
+ userPickerShown = false
+ }
+ } catch {
+ await MainActor.run {
+ switchingProfile = false
+ showAlert(
+ NSLocalizedString("Error switching profile!", comment: "alertTitle"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
+ )
+ }
+ }
+ }
+ })
+ .clipShape(sectionShape)
+ }
+
+ private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View {
+ ZStack(alignment: .bottom) {
+ settingsRow(icon, color: theme.colors.secondary) {
+ Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, rowPadding)
+ .padding(.vertical, rowVerticalPadding)
+ .modifier(ListRow { activeSheet = sheet })
+ .disabled(disabled)
+ if showDivider {
+ Divider().padding(.leading, 52)
+ }
}
- .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}
}
-private func unreadCounter(_ unread: Int, color: Color) -> some View {
- unreadCountText(unread)
- .font(.caption)
- .foregroundColor(.white)
- .padding(.horizontal, 4)
- .frame(minWidth: 18, minHeight: 18)
- .background(color)
- .cornerRadius(10)
+struct UnreadBadge: View {
+ var userInfo: UserInfo
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+
+ var body: some View {
+ let size = dynamicSize(userFont).chatInfoSize
+ unreadCountText(userInfo.unreadCount)
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: size, minHeight: size)
+ .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
}
+struct ListRow: ViewModifier {
+ @Environment(\.colorScheme) private var colorScheme: ColorScheme
+ @State private var touchDown = false
+ let action: () -> Void
+
+ func body(content: Content) -> some View {
+ ZStack {
+ elevatedSecondarySystemGroupedBackground
+ Color(.systemGray4).opacity(touchDown ? 1 : 0)
+ content
+ TouchOverlay(touchDown: $touchDown, action: action)
+ }
+ }
+
+ var elevatedSecondarySystemGroupedBackground: Color {
+ switch colorScheme {
+ case .dark: Color(0xFF2C2C2E)
+ default: Color(0xFFFFFFFF)
+ }
+ }
+
+ struct TouchOverlay: UIViewRepresentable {
+ @Binding var touchDown: Bool
+ let action: () -> Void
+
+ func makeUIView(context: Context) -> TouchView {
+ let touchView = TouchView()
+ let gesture = UILongPressGestureRecognizer(
+ target: touchView,
+ action: #selector(touchView.longPress(gesture:))
+ )
+ gesture.delegate = touchView
+ gesture.minimumPressDuration = 0
+ touchView.addGestureRecognizer(gesture)
+ return touchView
+ }
+
+ func updateUIView(_ touchView: TouchView, context: Context) {
+ touchView.representer = self
+ }
+
+ class TouchView: UIView, UIGestureRecognizerDelegate {
+ var representer: TouchOverlay?
+ private var startLocation: CGPoint?
+ private var task: Task?
+
+ @objc
+ func longPress(gesture: UILongPressGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ startLocation = gesture.location(in: nil)
+ task = Task {
+ do {
+ try await Task.sleep(nanoseconds: 200_000000)
+ await MainActor.run { representer?.touchDown = true }
+ } catch { }
+ }
+ case .ended:
+ if hitTest(gesture.location(in: self), with: nil) == self {
+ representer?.action()
+ }
+ task?.cancel()
+ representer?.touchDown = false
+ case .changed:
+ if let startLocation {
+ let location = gesture.location(in: nil)
+ let dx = location.x - startLocation.x
+ let dy = location.y - startLocation.y
+ if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
+ }
+ case .cancelled, .failed:
+ task?.cancel()
+ representer?.touchDown = false
+ default: break
+ }
+ }
+
+ func gestureRecognizer(
+ _: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
+ ) -> Bool { true }
+ }
+ }
+}
+
+
struct UserPicker_Previews: PreviewProvider {
static var previews: some View {
+ @State var activeSheet: UserPickerSheet?
+
let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker(
- showSettings: Binding.constant(false),
- showConnectDesktop: Binding.constant(false),
- userPickerVisible: Binding.constant(true)
+ userPickerShown: .constant(true),
+ activeSheet: $activeSheet
)
.environmentObject(m)
}
diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
index 4b43610236..898a47cc86 100644
--- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
+++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
@@ -151,7 +151,7 @@ struct ContactListNavLink: View {
}
private var verifiedIcon: Text {
- (Text(Image(systemName: "checkmark.shield")) + Text(" "))
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
diff --git a/apps/ios/Shared/Views/Database/ChatArchiveView.swift b/apps/ios/Shared/Views/Database/ChatArchiveView.swift
deleted file mode 100644
index 3ab4ac9a31..0000000000
--- a/apps/ios/Shared/Views/Database/ChatArchiveView.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-//
-// ChatArchiveView.swift
-// SimpleXChat
-//
-// Created by Evgeny on 23/06/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-struct ChatArchiveView: View {
- @EnvironmentObject var theme: AppTheme
- var archiveName: String
- @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
- @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
- @State private var showDeleteAlert = false
-
- var body: some View {
- let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName)
- let fileTs = chatArchiveTimeDefault.get()
- List {
- Section {
- settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
- Button {
- showShareSheet(items: [fileUrl])
- } label: {
- Text("Save archive")
- }
- }
- settingsRow("trash", color: theme.colors.secondary) {
- Button {
- showDeleteAlert = true
- } label: {
- Text("Delete archive").foregroundColor(.red)
- }
- }
- } header: {
- Text("Chat archive")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("Created on \(fileTs)")
- .foregroundColor(theme.colors.secondary)
- }
- }
- .alert(isPresented: $showDeleteAlert) {
- Alert(
- title: Text("Delete chat archive?"),
- primaryButton: .destructive(Text("Delete")) {
- do {
- try FileManager.default.removeItem(atPath: fileUrl.path)
- chatArchiveName = nil
- chatArchiveTime = 0
- } catch let error {
- logger.error("removeItem error \(String(describing: error))")
- }
- },
- secondaryButton: .cancel()
- )
- }
- }
-}
-
-struct ChatArchiveView_Previews: PreviewProvider {
- static var previews: some View {
- ChatArchiveView(archiveName: "")
- }
-}
diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
index be167b92b9..3cd37e4930 100644
--- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
@@ -48,6 +48,8 @@ struct DatabaseEncryptionView: View {
@State private var confirmNewKey = ""
@State private var currentKeyShown = false
+ let stopChatRunBlockStartChat: (Binding, @escaping () async throws -> Bool) -> Void
+
var body: some View {
ZStack {
List {
@@ -134,46 +136,61 @@ struct DatabaseEncryptionView: View {
.onAppear {
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
}
- .disabled(m.chatRunning != false)
+ .disabled(progressIndicator)
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
}
- private func encryptDatabase() {
- progressIndicator = true
- Task {
- do {
- encryptionStartedDefault.set(true)
- encryptionStartedAtDefault.set(Date.now)
- if !m.chatDbChanged {
- try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
- }
- try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
- encryptionStartedDefault.set(false)
- initialRandomDBPassphraseGroupDefault.set(false)
- if migration {
- storeDBPassphraseGroupDefault.set(useKeychain)
- }
- if useKeychain {
- if kcDatabasePassword.set(newKey) {
- await resetFormAfterEncryption(true)
- await operationEnded(.databaseEncrypted)
- } else {
- await resetFormAfterEncryption()
- await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
- }
- } else {
- if migration {
- removePassphraseFromKeyChain()
- }
- await resetFormAfterEncryption()
+ private func encryptDatabaseAsync() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ encryptionStartedDefault.set(true)
+ encryptionStartedAtDefault.set(Date.now)
+ if !m.chatDbChanged {
+ try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
+ }
+ try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
+ encryptionStartedDefault.set(false)
+ initialRandomDBPassphraseGroupDefault.set(false)
+ if migration {
+ storeDBPassphraseGroupDefault.set(useKeychain)
+ }
+ if useKeychain {
+ if kcDatabasePassword.set(newKey) {
+ await resetFormAfterEncryption(true)
await operationEnded(.databaseEncrypted)
- }
- } catch let error {
- if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
- await operationEnded(.currentPassphraseError)
} else {
- await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
+ await resetFormAfterEncryption()
+ await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
}
+ } else {
+ if migration {
+ removePassphraseFromKeyChain()
+ }
+ await resetFormAfterEncryption()
+ await operationEnded(.databaseEncrypted)
+ }
+ return true
+ } catch let error {
+ if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
+ await operationEnded(.currentPassphraseError)
+ } else {
+ await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
+ }
+ return false
+ }
+ }
+
+ private func encryptDatabase() {
+ // it will try to stop and start the chat in case of: non-migration && successful encryption. In migration the chat will remain stopped
+ if migration {
+ Task {
+ await encryptDatabaseAsync()
+ }
+ } else {
+ stopChatRunBlockStartChat($progressIndicator) {
+ return await encryptDatabaseAsync()
}
}
}
@@ -371,6 +388,6 @@ func validKey(_ s: String) -> Bool {
struct DatabaseEncryptionView_Previews: PreviewProvider {
static var previews: some View {
- DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
+ DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false, stopChatRunBlockStartChat: { _, _ in true })
}
}
diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
index 9d71e2a788..6222a28fb4 100644
--- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
@@ -11,6 +11,7 @@ import SimpleXChat
struct DatabaseErrorView: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
@State var status: DBMigrationResult
@State private var dbKey = ""
@State private var storedDBKey = kcDatabasePassword.get()
@@ -28,23 +29,39 @@ struct DatabaseErrorView: View {
}
@ViewBuilder private func databaseErrorView() -> some View {
- VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .center, spacing: 20) {
switch status {
case let .errorNotADatabase(dbFile):
if useKeychain && storedDBKey != nil && storedDBKey != "" {
titleText("Wrong database passphrase")
Text("Database passphrase is different from saved in the keychain.")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+
databaseKeyField(onSubmit: saveAndRunChat)
- saveAndOpenButton()
- fileNameText(dbFile)
+ Spacer()
+ VStack(spacing: 10) {
+ saveAndOpenButton()
+ fileNameText(dbFile)
+ }
} else {
titleText("Encrypted database")
Text("Database passphrase is required to open chat.")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+ .padding(.bottom, 5)
+
if useKeychain {
databaseKeyField(onSubmit: saveAndRunChat)
+ Spacer()
saveAndOpenButton()
} else {
databaseKeyField(onSubmit: { runChat() })
+ Spacer()
openChatButton()
}
}
@@ -52,73 +69,105 @@ struct DatabaseErrorView: View {
switch migrationError {
case let .upgrade(upMigrations):
titleText("Database upgrade")
- Button("Upgrade and open chat") { runChat(confirmMigrations: .yesUp) }
- fileNameText(dbFile)
migrationsText(upMigrations.map(\.upName))
+ Spacer()
+ VStack(spacing: 10) {
+ Button("Upgrade and open chat") {
+ runChat(confirmMigrations: .yesUp)
+ }.buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ fileNameText(dbFile)
+ }
case let .downgrade(downMigrations):
titleText("Database downgrade")
- Text("Warning: you may lose some data!").bold()
- Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
- fileNameText(dbFile)
+ Text("Warning: you may lose some data!")
+ .bold()
+ .padding(.horizontal, 25)
+ .multilineTextAlignment(.center)
+
migrationsText(downMigrations)
+ Spacer()
+ VStack(spacing: 10) {
+ Button("Downgrade and open chat") {
+ runChat(confirmMigrations: .yesUpDown)
+ }.buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ fileNameText(dbFile)
+ }
case let .migrationError(mtrError):
titleText("Incompatible database version")
- fileNameText(dbFile)
- Text("Error: ") + Text(mtrErrorDescription(mtrError))
+ fileNameText(dbFile, font: .callout)
+ errorView(Text(mtrErrorDescription(mtrError)))
}
case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error")
- fileNameText(dbFile)
- Text("Error: \(migrationSQLError)")
+ fileNameText(dbFile, font: .callout)
+ errorView(Text("Error: \(migrationSQLError)"))
case .errorKeychain:
titleText("Keychain error")
- Text("Cannot access keychain to save database password")
+ errorView(Text("Cannot access keychain to save database password"))
case .invalidConfirmation:
// this can only happen if incorrect parameter is passed
- Text(String("Invalid migration confirmation")).font(.title)
+ titleText("Invalid migration confirmation")
+ errorView()
+
case let .unknown(json):
titleText("Database error")
- Text("Unknown database error: \(json)")
+ errorView(Text("Unknown database error: \(json)"))
case .ok:
EmptyView()
}
if showRestoreDbButton {
- Spacer().frame(height: 10)
+ Spacer()
Text("The attempt to change database passphrase was not completed.")
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
+ .font(.footnote)
+
restoreDbButton()
}
}
- .padding()
+ .padding(.horizontal, 25)
+ .padding(.top, 75)
+ .padding(.bottom, 25)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
}
- private func titleText(_ s: LocalizedStringKey) -> Text {
- Text(s).font(.title)
+ private func titleText(_ s: LocalizedStringKey) -> some View {
+ Text(s).font(.largeTitle).bold().multilineTextAlignment(.center)
}
- private func fileNameText(_ f: String) -> Text {
- Text("File: \((f as NSString).lastPathComponent)")
+ private func fileNameText(_ f: String, font: Font = .caption) -> Text {
+ Text("File: \((f as NSString).lastPathComponent)").font(font)
}
- private func migrationsText(_ ms: [String]) -> Text {
- Text("Migrations: \(ms.joined(separator: ", "))")
+ private func migrationsText(_ ms: [String]) -> some View {
+ (Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 25)
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
+ .padding(.vertical, 10)
+ .padding(.horizontal)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(uiColor: .tertiarySystemFill))
+ )
}
private func saveAndOpenButton() -> some View {
Button("Save passphrase and open chat") {
saveAndRunChat()
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func openChatButton() -> some View {
Button("Open chat") {
runChat()
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func saveAndRunChat() {
@@ -192,8 +241,9 @@ struct DatabaseErrorView: View {
secondaryButton: .cancel()
))
} label: {
- Text("Restore database backup").foregroundColor(.red)
+ Text("Restore database backup")
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func restoreDb() {
@@ -208,6 +258,23 @@ struct DatabaseErrorView: View {
))
}
}
+
+ private func errorView(_ s: Text? = nil) -> some View {
+ VStack(spacing: 35) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .frame(width: 50, height: 50)
+ .foregroundColor(.red)
+
+ if let text = s {
+ text
+ .multilineTextAlignment(.center)
+ .font(.footnote)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ }
}
struct DatabaseErrorView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift
index 6b6d3796dc..4a367f7722 100644
--- a/apps/ios/Shared/Views/Database/DatabaseView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseView.swift
@@ -44,8 +44,9 @@ enum DatabaseAlert: Identifiable {
struct DatabaseView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
+ let dismissSettingsSheet: DismissAction
@State private var runChat = false
+ @State private var stoppingChat = false
@State private var alert: DatabaseAlert? = nil
@State private var showFileImporter = false
@State private var importedArchivePath: URL?
@@ -57,6 +58,8 @@ struct DatabaseView: View {
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var appFilesCountAndSize: (Int, Int)?
+ @State private var showDatabaseEncryptionView = false
+
@State var chatItemTTL: ChatItemTTL
@State private var currentChatItemTTL: ChatItemTTL = .none
@@ -69,7 +72,20 @@ struct DatabaseView: View {
}
}
+ @ViewBuilder
private func chatDatabaseView() -> some View {
+ NavigationLink(isActive: $showDatabaseEncryptionView) {
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
+ stopChatRunBlockStartChat(false, progressIndicator, block)
+ })
+ .navigationTitle("Database passphrase")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+
List {
let stopped = m.chatRunning == false
Section {
@@ -101,9 +117,10 @@ struct DatabaseView: View {
isOn: $runChat
)
.onChange(of: runChat) { _ in
- if (runChat) {
- startChat()
- } else {
+ if runChat {
+ DatabaseView.startChat($runChat, $progressIndicator)
+ } else if !stoppingChat {
+ stoppingChat = false
alert = .stopChat
}
}
@@ -123,7 +140,9 @@ struct DatabaseView: View {
let color: Color = unencrypted ? .orange : theme.colors.secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink {
- DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
+ stopChatRunBlockStartChat(false, progressIndicator, block)
+ })
.navigationTitle("Database passphrase")
.modifier(ThemedBackground(grouped: true))
} label: {
@@ -133,9 +152,14 @@ struct DatabaseView: View {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
- alert = .exportProhibited
+ showDatabaseEncryptionView = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ alert = .exportProhibited
+ }
} else {
- exportArchive()
+ stopChatRunBlockStartChat(stopped, $progressIndicator) {
+ await exportArchive()
+ }
}
}
}
@@ -144,20 +168,6 @@ struct DatabaseView: View {
showFileImporter = true
}
}
- if let archiveName = chatArchiveName {
- let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
- ? "Old database archive"
- : "New database archive"
- settingsRow("archivebox", color: theme.colors.secondary) {
- NavigationLink {
- ChatArchiveView(archiveName: archiveName)
- .navigationTitle(title)
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text(title)
- }
- }
- }
settingsRow("trash.slash", color: theme.colors.secondary) {
Button("Delete database", role: .destructive) {
alert = .deleteChat
@@ -167,14 +177,10 @@ struct DatabaseView: View {
Text("Chat database")
.foregroundColor(theme.colors.secondary)
} footer: {
- Text(
- stopped
- ? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
- : "Stop chat to enable database actions"
- )
+ Text("You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.")
.foregroundColor(theme.colors.secondary)
}
- .disabled(!stopped)
+ .disabled(progressIndicator)
if case .group = dbContainer, legacyDatabase {
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
@@ -190,7 +196,7 @@ struct DatabaseView: View {
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
alert = .deleteFilesAndMedia
}
- .disabled(!stopped || appFilesCountAndSize?.0 == 0)
+ .disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
} header: {
Text("Files & media")
.foregroundColor(theme.colors.secondary)
@@ -255,7 +261,10 @@ struct DatabaseView: View {
title: Text("Import chat database?"),
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Import")) {
- importArchive(fileURL)
+ stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
+ _ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert)
+ return true
+ }
},
secondaryButton: .cancel()
)
@@ -263,19 +272,15 @@ struct DatabaseView: View {
return Alert(title: Text("Error: no database file"))
}
case .archiveImported:
- return Alert(
- title: Text("Chat database imported"),
- message: Text("Restart the app to use imported chat database")
- )
+ let (title, message) = archiveImportedAlertText()
+ return Alert(title: Text(title), message: Text(message))
case let .archiveImportedWithErrors(errs):
- return Alert(
- title: Text("Chat database imported"),
- message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
- )
+ let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
+ return Alert(title: Text(title), message: Text(message))
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
+ message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath])
}
@@ -285,15 +290,17 @@ struct DatabaseView: View {
title: Text("Delete chat profile?"),
message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Delete")) {
- deleteChat()
+ let wasStopped = m.chatRunning == false
+ stopChatRunBlockStartChat(wasStopped, $progressIndicator) {
+ _ = await deleteChat()
+ return true
+ }
},
secondaryButton: .cancel()
)
case .chatDeleted:
- return Alert(
- title: Text("Chat database deleted"),
- message: Text("Restart the app to create a new chat profile")
- )
+ let (title, message) = chatDeletedAlertText()
+ return Alert(title: Text(title), message: Text(message))
case .deleteLegacyDatabase:
return Alert(
title: Text("Delete old database?"),
@@ -308,7 +315,10 @@ struct DatabaseView: View {
title: Text("Delete files and media?"),
message: Text("This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain."),
primaryButton: .destructive(Text("Delete")) {
- deleteFiles()
+ stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
+ deleteFiles()
+ return true
+ }
},
secondaryButton: .cancel()
)
@@ -328,95 +338,180 @@ struct DatabaseView: View {
}
}
- private func authStopChat() {
+ private func authStopChat(_ onStop: (() -> Void)? = nil) {
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
switch laResult {
- case .success: stopChat()
- case .unavailable: stopChat()
+ case .success: stopChat(onStop)
+ case .unavailable: stopChat(onStop)
case .failed: withAnimation { runChat = true }
}
}
} else {
- stopChat()
+ stopChat(onStop)
}
}
- private func stopChat() {
+ private func stopChat(_ onStop: (() -> Void)? = nil) {
Task {
do {
try await stopChatAsync()
+ onStop?()
} catch let error {
await MainActor.run {
runChat = true
- alert = .error(title: "Error stopping chat", error: responseError(error))
+ showAlert("Error stopping chat", message: responseError(error))
}
}
}
}
- private func exportArchive() {
- progressIndicator = true
- Task {
- do {
- let (archivePath, archiveErrors) = try await exportChatArchive()
- if archiveErrors.isEmpty {
- showShareSheet(items: [archivePath])
- await MainActor.run { progressIndicator = false }
- } else {
- await MainActor.run {
- alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
- progressIndicator = false
+ func stopChatRunBlockStartChat(
+ _ stopped: Bool,
+ _ progressIndicator: Binding,
+ _ block: @escaping () async throws -> Bool
+ ) {
+ // if the chat was running, the sequence is: stop chat, run block, start chat.
+ // Otherwise, just run block and do nothing - the toggle will be visible anyway and the user can start the chat or not
+ if stopped {
+ Task {
+ do {
+ _ = try await block()
+ } catch {
+ logger.error("Error while executing block: \(error)")
+ }
+ }
+ } else {
+ authStopChat {
+ stoppingChat = true
+ runChat = false
+ Task {
+ // if it throws, let's start chat again anyway
+ var canStart = false
+ do {
+ canStart = try await block()
+ } catch {
+ logger.error("Error executing block: \(error)")
+ canStart = true
+ }
+ if canStart {
+ await MainActor.run {
+ DatabaseView.startChat($runChat, $progressIndicator)
+ }
}
}
+ }
+ }
+ }
+
+ static func startChat(_ runChat: Binding, _ progressIndicator: Binding) {
+ progressIndicator.wrappedValue = true
+ let m = ChatModel.shared
+ if m.chatDbChanged {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ resetChatCtrl()
+ do {
+ let hadDatabase = hasDatabase()
+ try initializeChat(start: true)
+ m.chatDbChanged = false
+ AppChatState.shared.set(.active)
+ if m.chatDbStatus != .ok || !hadDatabase {
+ // Hide current view and show `DatabaseErrorView`
+ dismissAllSheets(animated: true)
+ }
+ } catch let error {
+ fatalError("Error starting chat \(responseError(error))")
+ }
+ progressIndicator.wrappedValue = false
+ }
+ } else {
+ do {
+ _ = try apiStartChat()
+ runChat.wrappedValue = true
+ m.chatRunning = true
+ ChatReceiver.shared.start()
+ chatLastStartGroupDefault.set(Date.now)
+ AppChatState.shared.set(.active)
} catch let error {
+ runChat.wrappedValue = false
+ showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
+ }
+ progressIndicator.wrappedValue = false
+ }
+ }
+
+ private func exportArchive() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ let (archivePath, archiveErrors) = try await exportChatArchive()
+ if archiveErrors.isEmpty {
+ showShareSheet(items: [archivePath])
+ await MainActor.run { progressIndicator = false }
+ } else {
await MainActor.run {
- alert = .error(title: "Error exporting chat database", error: responseError(error))
+ alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
progressIndicator = false
}
}
+ } catch let error {
+ await MainActor.run {
+ alert = .error(title: "Error exporting chat database", error: responseError(error))
+ progressIndicator = false
+ }
}
+ return false
}
- private func importArchive(_ archivePath: URL) {
+ static func importArchive(
+ _ archivePath: URL,
+ _ progressIndicator: Binding,
+ _ alert: Binding
+ ) async -> Bool {
if archivePath.startAccessingSecurityScopedResource() {
- progressIndicator = true
- Task {
- do {
- try await apiDeleteStorage()
- try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
- do {
- let config = ArchiveConfig(archivePath: archivePath.path)
- let archiveErrors = try await apiImportArchive(config: config)
- _ = kcDatabasePassword.remove()
- if archiveErrors.isEmpty {
- await operationEnded(.archiveImported)
- } else {
- await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors))
- }
- } catch let error {
- await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
- }
- } catch let error {
- await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
- }
- archivePath.stopAccessingSecurityScopedResource()
+ await MainActor.run {
+ progressIndicator.wrappedValue = true
}
+ do {
+ try await apiDeleteStorage()
+ try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
+ do {
+ let config = ArchiveConfig(archivePath: archivePath.path)
+ let archiveErrors = try await apiImportArchive(config: config)
+ shouldImportAppSettingsDefault.set(true)
+ _ = kcDatabasePassword.remove()
+ if archiveErrors.isEmpty {
+ await operationEnded(.archiveImported, progressIndicator, alert)
+ } else {
+ await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert)
+ }
+ return true
+ } catch let error {
+ await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
+ }
+ } catch let error {
+ await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
+ }
+ archivePath.stopAccessingSecurityScopedResource()
} else {
- alert = .error(title: "Error accessing database file")
+ showAlert("Error accessing database file")
}
+ return false
}
- private func deleteChat() {
- progressIndicator = true
- Task {
- do {
- try await deleteChatAsync()
- await operationEnded(.chatDeleted)
- appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
- } catch let error {
- await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
- }
+ private func deleteChat() async -> Bool {
+ await MainActor.run {
+ progressIndicator = true
+ }
+ do {
+ try await deleteChatAsync()
+ appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
+ await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
+ return true
+ } catch let error {
+ await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
+ return false
}
}
@@ -428,39 +523,28 @@ struct DatabaseView: View {
}
}
- private func operationEnded(_ dbAlert: DatabaseAlert) async {
+ private static func operationEnded(_ dbAlert: DatabaseAlert, _ progressIndicator: Binding, _ alert: Binding) async {
await MainActor.run {
+ let m = ChatModel.shared
m.chatDbChanged = true
m.chatInitialized = false
- progressIndicator = false
- alert = dbAlert
+ progressIndicator.wrappedValue = false
}
- }
-
- private func startChat() {
- if m.chatDbChanged {
- showSettings = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- resetChatCtrl()
- do {
- try initializeChat(start: true)
- m.chatDbChanged = false
- AppChatState.shared.set(.active)
- } catch let error {
- fatalError("Error starting chat \(responseError(error))")
- }
- }
- } else {
- do {
- _ = try apiStartChat()
- runChat = true
- m.chatRunning = true
- ChatReceiver.shared.start()
- chatLastStartGroupDefault.set(Date.now)
- AppChatState.shared.set(.active)
- } catch let error {
- runChat = false
- alert = .error(title: "Error starting chat", error: responseError(error))
+ await withCheckedContinuation { cont in
+ let okAlertActionWaiting = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default, handler: { _ in cont.resume() })
+ // show these alerts globally so they are visible when all sheets will be hidden
+ if case .archiveImported = dbAlert {
+ let (title, message) = archiveImportedAlertText()
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else if case .archiveImportedWithErrors(let errs) = dbAlert {
+ let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else if case .chatDeleted = dbAlert {
+ let (title, message) = chatDeletedAlertText()
+ showAlert(title, message: message, actions: { [okAlertActionWaiting] })
+ } else {
+ alert.wrappedValue = dbAlert
+ cont.resume()
}
}
}
@@ -491,7 +575,7 @@ struct DatabaseView: View {
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
do {
let chats = try apiGetChats()
- m.updateChats(with: chats)
+ m.updateChats(chats)
} catch let error {
logger.error("apiGetChats: cannot update chats \(responseError(error))")
}
@@ -503,8 +587,28 @@ struct DatabaseView: View {
}
}
-func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
- return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
+private func archiveImportedAlertText() -> (String, String) {
+ (
+ NSLocalizedString("Chat database imported", comment: ""),
+ NSLocalizedString("Restart the app to use imported chat database", comment: "")
+ )
+}
+private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
+ (
+ NSLocalizedString("Chat database imported", comment: ""),
+ NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs)
+ )
+}
+
+private func chatDeletedAlertText() -> (String, String) {
+ (
+ NSLocalizedString("Chat database deleted", comment: ""),
+ NSLocalizedString("Restart the app to create a new chat profile", comment: "")
+ )
+}
+
+func archiveErrorsText(_ errs: [ArchiveError]) -> String {
+ return "\n" + errs.map(showArchiveError).joined(separator: "\n")
func showArchiveError(_ err: ArchiveError) -> String {
switch err {
@@ -533,7 +637,9 @@ func deleteChatAsync() async throws {
}
struct DatabaseView_Previews: PreviewProvider {
+ @Environment(\.dismiss) static var mockDismiss
+
static var previews: some View {
- DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
+ DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none)
}
}
diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
index e79f24c6d9..79c0a42ae0 100644
--- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
+++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
@@ -117,7 +117,7 @@ struct MigrateToAppGroupView: View {
setV3DBMigration(.migration_error)
migrationError = "Error starting chat: \(responseError(error))"
}
- deleteOldArchive()
+ deleteOldChatArchive()
} label: {
Text("Start chat")
.font(.title)
@@ -235,14 +235,16 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
let errs = try await apiExportArchive(config: config)
if storagePath == nil {
- deleteOldArchive()
+ deleteOldChatArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime)
}
return (archivePath, errs)
}
-func deleteOldArchive() {
+/// Deprecated. Remove in the end of 2025. All unused archives should be deleted for the most users til then.
+/// Remove DEFAULT_CHAT_ARCHIVE_NAME and DEFAULT_CHAT_ARCHIVE_TIME as well
+func deleteOldChatArchive() {
let d = UserDefaults.standard
if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) {
do {
diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift
index 0ade1c0d8e..1e334367e8 100644
--- a/apps/ios/Shared/Views/Helpers/AppSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift
@@ -11,6 +11,12 @@ import SwiftUI
class AppSheetState: ObservableObject {
static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false
+
+ func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
+ !protectScreen || scenePhaseActive
+ ? RedactionReasons()
+ : RedactionReasons.placeholder
+ }
}
private struct PrivacySensitive: ViewModifier {
@@ -19,11 +25,7 @@ private struct PrivacySensitive: ViewModifier {
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
func body(content: Content) -> some View {
- if !protectScreen {
- content
- } else {
- content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
- }
+ content.redacted(reason: appSheetState.redactionReasons(protectScreen))
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
index 477dc567eb..9aa6ac86cf 100644
--- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
@@ -14,50 +14,55 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
- struct ClipShape: Shape {
- let maxCornerRadius: Double
-
- func path(in rect: CGRect) -> Path {
- Path(
- roundedRect: rect,
- cornerRadius: min((rect.height / 2), maxCornerRadius),
- style: .circular
- )
- }
- }
-
+ @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
+ @AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
+ private let chatItem: (content: CIContent, chatDir: CIDirection)?
+ private let tailVisible: Bool
+
init() {
- clipShape = ClipShape(
- maxCornerRadius: 18
- )
+ self.chatItem = nil
+ self.tailVisible = false
+ }
+
+ init(_ ci: ChatItem, tailVisible: Bool) {
+ self.chatItem = (ci.content, ci.chatDir)
+ self.tailVisible = tailVisible
}
- init(_ chatItem: ChatItem) {
- clipShape = ClipShape(
- maxCornerRadius: {
- switch chatItem.content {
- case
- .sndMsgContent,
+ private func shapeStyle() -> ChatItemShape.Style {
+ if let ci = chatItem {
+ switch ci.content {
+ case
+ .sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
- .rcvGroupInvitation,
- .sndGroupInvitation,
- .sndDeleted,
- .rcvDeleted,
.rcvIntegrityError,
- .sndModerated,
- .rcvModerated,
- .rcvBlocked,
- .invalidJSON: 18
- default: 8
+ .invalidJSON:
+ let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
+ false
+ } else {
+ tailVisible
}
- }()
- )
+ return tailEnabled
+ ? .bubble(
+ padding: ci.chatDir.sent ? .trailing : .leading,
+ tailVisible: tail
+ )
+ : .roundRect(radius: msgRectMaxRadius)
+ case .rcvGroupInvitation, .sndGroupInvitation:
+ return .roundRect(radius: msgRectMaxRadius)
+ default: return .roundRect(radius: 8)
+ }
+ } else {
+ return .roundRect(radius: msgRectMaxRadius)
+ }
}
-
- private let clipShape: ClipShape
-
+
func body(content: Content) -> some View {
+ let clipShape = ChatItemShape(
+ roundness: roundness,
+ style: shapeStyle()
+ )
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
@@ -65,4 +70,106 @@ struct ChatItemClipped: ViewModifier {
}
}
+struct ChatTailPadding: ViewModifier {
+ func body(content: Content) -> some View {
+ content.padding(.horizontal, -msgTailWidth)
+ }
+}
+private let msgRectMaxRadius: Double = 18
+private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
+private let msgTailWidth: Double = 9
+private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
+private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
+
+struct ChatItemShape: Shape {
+ fileprivate enum Style {
+ case bubble(padding: HorizontalEdge, tailVisible: Bool)
+ case roundRect(radius: Double)
+ }
+
+ fileprivate let roundness: Double
+ fileprivate let style: Style
+
+ func path(in rect: CGRect) -> Path {
+ switch style {
+ case let .bubble(padding, tailVisible):
+ let w = rect.width
+ let h = rect.height
+ let rxMax = min(msgBubbleMaxRadius, w / 2)
+ let ryMax = min(msgBubbleMaxRadius, h / 2)
+ let rx = roundness * rxMax
+ let ry = roundness * ryMax
+ let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
+ var path = Path()
+ // top side
+ path.move(to: CGPoint(x: rx, y: 0))
+ path.addLine(to: CGPoint(x: w - rx, y: 0))
+ if roundness > 0 {
+ // top-right corner
+ path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
+ }
+ if rect.height > 2 * ry {
+ // right side
+ path.addLine(to: CGPoint(x: w, y: h - ry))
+ }
+ if roundness > 0 {
+ // bottom-right corner
+ path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
+ }
+ // bottom side
+ if tailVisible {
+ path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
+ if roundness > 0 {
+ // bottom-left tail
+ // distance of control point from touch point, calculated via ratios
+ let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
+ // tail control point
+ let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
+ // bottom-left tail curve
+ path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
+ } else {
+ path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
+ }
+ if rect.height > ry + tailHeight {
+ // left side
+ path.addLine(to: CGPoint(x: 0, y: ry))
+ }
+ } else {
+ path.addLine(to: CGPoint(x: rx, y: h))
+ path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
+ if rect.height > 2 * ry {
+ // left side
+ path.addLine(to: CGPoint(x: 0, y: ry))
+ }
+ }
+ if roundness > 0 {
+ // top-left corner
+ path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
+ }
+ path.closeSubpath()
+ return switch padding {
+ case .leading: path
+ case .trailing: path
+ .scale(x: -1, y: 1, anchor: .center)
+ .path(in: rect)
+ }
+ case let .roundRect(radius):
+ return Path(roundedRect: rect, cornerRadius: radius * roundness)
+ }
+ }
+
+ var offset: Double? {
+ switch style {
+ case let .bubble(padding, isTailVisible):
+ if isTailVisible {
+ switch padding {
+ case .leading: -msgTailWidth
+ case .trailing: msgTailWidth
+ }
+ } else { 0 }
+ case .roundRect: 0
+ }
+ }
+
+}
diff --git a/apps/ios/Shared/Views/Helpers/ChatWallpaper.swift b/apps/ios/Shared/Views/Helpers/ChatWallpaper.swift
index 9eea278d4e..cc5be9e7bb 100644
--- a/apps/ios/Shared/Views/Helpers/ChatWallpaper.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatWallpaper.swift
@@ -100,7 +100,7 @@ extension PresetWallpaper {
scale
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
type.scale
- } else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
+ } else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename && $0.base == base })?.wallpaper?.scale {
scale
} else {
Float(1.0)
diff --git a/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift
new file mode 100644
index 0000000000..dca413dafe
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift
@@ -0,0 +1,21 @@
+//
+// Test.swift
+// SimpleX (iOS)
+//
+// Created by Levitating Pineapple on 31/08/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+extension View {
+ @ViewBuilder
+ func invertedForegroundStyle(enabled: Bool = true) -> some View {
+ if enabled {
+ foregroundStyle(Material.ultraThin)
+ .environment(\.colorScheme, .dark)
+ .grayscale(1)
+ .contrast(-20)
+ } else { self }
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift b/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift
deleted file mode 100644
index 45d766ddfd..0000000000
--- a/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// KeyboardPadding.swift
-// SimpleX (iOS)
-//
-// Created by Evgeny on 10/07/2023.
-// Copyright © 2023 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-
-extension View {
- @ViewBuilder func keyboardPadding() -> some View {
- if #available(iOS 17.0, *) {
- GeometryReader { g in
- self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
- }
- } else {
- self
- }
- }
-}
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
index 248504c59b..3eedd56441 100644
--- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -20,7 +20,7 @@ struct ProfileImage: View {
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
- if let uiImage = UIImage(base64Encoded: imageStr) {
+ if let uiImage = imageFromBase64(imageStr) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 936c6cb3ab..b8de0e4ceb 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -8,15 +8,63 @@
import SwiftUI
-func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
+func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
- let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
+ let rootViewController = keyWindow.rootViewController {
+ // Find the top-most presented view controller
+ var topController = rootViewController
+ while let presentedViewController = topController.presentedViewController {
+ topController = presentedViewController
+ }
+ return topController
+ }
+ return nil
+}
+
+func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
+ if let topController = getTopViewController() {
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)
+ activityViewController.completionWithItemsHandler = { _, _, _, _ in
+ completed()
+ }
+ }
+ topController.present(activityViewController, animated: true)
}
}
+
+func showAlert(
+ title: String,
+ message: String? = nil,
+ buttonTitle: String,
+ buttonAction: @escaping () -> Void,
+ cancelButton: Bool
+) -> Void {
+ if let topController = getTopViewController() {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
+ buttonAction()
+ })
+ if cancelButton {
+ alert.addAction(cancelAlertAction)
+ }
+ topController.present(alert, animated: true)
+ }
+}
+
+func showAlert(
+ _ title: String,
+ message: String? = nil,
+ actions: () -> [UIAlertAction] = { [okAlertAction] }
+) {
+ if let topController = getTopViewController() {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ for action in actions() { alert.addAction(action) }
+ topController.present(alert, animated: true)
+ }
+}
+
+let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
+
+let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
diff --git a/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift
new file mode 100644
index 0000000000..841d5c7eda
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift
@@ -0,0 +1,188 @@
+//
+// SwiftUISheet.swift
+// SimpleX (iOS)
+//
+// Created by user on 23/09/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+private let sheetAnimationDuration: Double = 0.35
+
+// Refrence: https://easings.net/
+private let easeOutCubic = UICubicTimingParameters(
+ controlPoint1: CGPoint(x: 0.215, y: 0.61),
+ controlPoint2: CGPoint(x: 0.355, y: 1)
+)
+
+struct Sheet: ViewModifier {
+ @Binding var isPresented: Bool
+ @ViewBuilder let sheetContent: () -> SheetContent
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+ SheetRepresentable(isPresented: $isPresented, content: sheetContent())
+ .allowsHitTesting(isPresented)
+ .ignoresSafeArea()
+ }
+ }
+}
+
+struct SheetRepresentable: UIViewControllerRepresentable {
+ @Binding var isPresented: Bool
+ let content: Content
+
+ func makeUIViewController(context: Context) -> Controller {
+ Controller(content: content, representer: self)
+ }
+
+ func updateUIViewController(_ sheetController: Controller, context: Context) {
+ sheetController.animate(isPresented: isPresented)
+ }
+
+ class Controller: UIViewController {
+ let hostingController: UIHostingController
+ private let animator = UIViewPropertyAnimator(
+ duration: sheetAnimationDuration,
+ timingParameters: easeOutCubic
+ )
+ private let representer: SheetRepresentable
+ private var retainedFraction: CGFloat = 0
+ private var sheetHeight: Double { hostingController.view.frame.height }
+ private var task: Task?
+
+ init(content: C, representer: SheetRepresentable) {
+ self.representer = representer
+ self.hostingController = UIHostingController(rootView: content)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
+
+ deinit {
+ animator.stopAnimation(true)
+ animator.finishAnimation(at: .current)
+ }
+
+ func animate(isPresented: Bool) {
+ let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed
+ let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
+ let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1)
+
+ if !isPresented && sheetFullyDismissed ||
+ isPresented && sheetFullyPresented ||
+ alreadyAnimating {
+ return
+ }
+
+ animator.pauseAnimation()
+ animator.isReversed = !isPresented
+ animator.continueAnimation(
+ withTimingParameters: isPresented
+ ? easeOutCubic
+ : UICubicTimingParameters(animationCurve: .easeIn),
+ durationFactor: 1 - animator.fractionComplete
+ )
+ handleVisibility()
+ }
+
+ func handleVisibility() {
+ if animator.isReversed {
+ task = Task {
+ do {
+ let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC))
+ try await Task.sleep(nanoseconds: sleepDuration)
+ view.isHidden = true
+ } catch { }
+ }
+ } else {
+ task?.cancel()
+ task = nil
+ view.isHidden = false
+ }
+ }
+
+ override func viewDidLoad() {
+ view.isHidden = true
+ view.backgroundColor = .clear
+ view.addGestureRecognizer(
+ UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
+ )
+ addChild(hostingController)
+ hostingController.didMove(toParent: self)
+ if let sheet = hostingController.view {
+ sheet.isHidden = true
+ sheet.clipsToBounds = true
+ sheet.layer.cornerRadius = 10
+ sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
+ sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
+ sheet.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(sheet)
+ NSLayoutConstraint.activate([
+ hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ // Ensures animations are only setup once
+ // on some iOS version `viewDidAppear` can get called on each state change.
+ if hostingController.view.isHidden {
+ hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight)
+ hostingController.view.isHidden = false
+ animator.pausesOnCompletion = true
+ animator.addAnimations {
+ self.hostingController.view.transform = .identity
+ self.view.backgroundColor = UIColor {
+ switch $0.userInterfaceStyle {
+ case .dark: .black.withAlphaComponent(0.290)
+ default: .black.withAlphaComponent(0.121)
+ }
+ }
+ }
+ animator.startAnimation()
+ animator.pauseAnimation()
+ }
+ }
+
+ @objc
+ func pan(gesture: UIPanGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ animator.isReversed = false
+ animator.pauseAnimation()
+ retainedFraction = animator.fractionComplete
+ case .changed:
+ animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight
+ case .ended, .cancelled:
+ let velocity = gesture.velocity(in: view).y
+ animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus
+ let defaultVelocity = sheetHeight / sheetAnimationDuration
+ let fractionRemaining = 1 - animator.fractionComplete
+ let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1)
+ animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining)
+ handleVisibility()
+ DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
+ self.representer.isPresented = !self.animator.isReversed
+ }
+ default: break
+ }
+ }
+
+ @objc
+ func tap(gesture: UITapGestureRecognizer) {
+ switch gesture.state {
+ case .ended:
+ if gesture.location(in: view).y < view.frame.height - sheetHeight {
+ representer.isPresented = false
+ }
+ default: break
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/StickyScrollView.swift b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift
new file mode 100644
index 0000000000..5799962778
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift
@@ -0,0 +1,61 @@
+//
+// StickyScrollView.swift
+// SimpleX (iOS)
+//
+// Created by user on 20/09/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct StickyScrollView: UIViewRepresentable {
+ @Binding var resetScroll: ResetScrollAction
+ @ViewBuilder let content: () -> Content
+
+ func makeUIView(context: Context) -> UIScrollView {
+ let hc = context.coordinator.hostingController
+ hc.view.backgroundColor = .clear
+ let sv = UIScrollView()
+ sv.showsHorizontalScrollIndicator = false
+ sv.addSubview(hc.view)
+ sv.delegate = context.coordinator
+ DispatchQueue.main.async {
+ resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) }
+ }
+ return sv
+ }
+
+ func updateUIView(_ scrollView: UIScrollView, context: Context) {
+ let hc = context.coordinator.hostingController
+ hc.rootView = content()
+ hc.view.frame.size = hc.view.intrinsicContentSize
+ scrollView.contentSize = hc.view.intrinsicContentSize
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(content: content())
+ }
+
+ class Coordinator: NSObject, UIScrollViewDelegate {
+ let hostingController: UIHostingController
+
+ init(content: Content) {
+ self.hostingController = UIHostingController(rootView: content)
+ }
+
+ func scrollViewWillEndDragging(
+ _ scrollView: UIScrollView,
+ withVelocity velocity: CGPoint,
+ targetContentOffset: UnsafeMutablePointer
+ ) {
+ if targetContentOffset.pointee.x < 32 {
+ targetContentOffset.pointee.x = 0
+ }
+ }
+ }
+}
+
+struct ResetScrollAction {
+ var action = { }
+ func callAsFunction() { action() }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift b/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
index ae94b4685c..9d5ae2e289 100644
--- a/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
+++ b/apps/ios/Shared/Views/Helpers/ThemeModeEditor.swift
@@ -16,6 +16,7 @@ struct UserWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
+ @State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -125,24 +126,27 @@ struct UserWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
- ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
- let importedFromString = imported.wallpaper?.importFromString()
- let importedType = importedFromString?.toAppWallpaper().type
- let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
- let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
- let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
- let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
- Task {
- await MainActor.run {
- themeModeOverride = res
- }
- await save(applyToMode, res)
- }
- }
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { imported in
+ let importedFromString = imported.wallpaper?.importFromString()
+ let importedType = importedFromString?.toAppWallpaper().type
+ let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
+ let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
+ let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
+ let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
+ Task {
+ await MainActor.run {
+ themeModeOverride = res
+ }
+ await save(applyToMode, res)
+ }
+ }
+ )
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -216,6 +220,7 @@ struct ChatWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
+ @State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -328,24 +333,27 @@ struct ChatWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: editColor)
- ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
- let importedFromString = imported.wallpaper?.importFromString()
- let importedType = importedFromString?.toAppWallpaper().type
- let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
- let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
- let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
- let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
- Task {
- await MainActor.run {
- themeModeOverride = res
- }
- await save(applyToMode, res)
- }
- }
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { imported in
+ let importedFromString = imported.wallpaper?.importFromString()
+ let importedType = importedFromString?.toAppWallpaper().type
+ let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
+ let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
+ let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
+ let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
+ Task {
+ await MainActor.run {
+ themeModeOverride = res
+ }
+ await save(applyToMode, res)
+ }
+ }
+ )
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
diff --git a/apps/ios/Shared/Views/Helpers/UserDefault.swift b/apps/ios/Shared/Views/Helpers/UserDefault.swift
new file mode 100644
index 0000000000..5f18465d20
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/UserDefault.swift
@@ -0,0 +1,62 @@
+//
+// UserDefault.swift
+// SimpleX (iOS)
+//
+// Created by user on 14/10/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import Combine
+
+@propertyWrapper
+public struct UserDefault: DynamicProperty {
+ @StateObject private var observer = UserDefaultObserver()
+ let initialValue: Value
+ let key: String
+ let store: UserDefaults
+
+ public init(
+ wrappedValue: Value,
+ _ key: String,
+ store: UserDefaults = .standard
+ ) {
+ self.initialValue = wrappedValue
+ self.key = key
+ self.store = store
+ }
+
+ public var wrappedValue: Value {
+ get {
+ // Observer can only be accessed after the property wrapper is installed in view (runtime exception)
+ observer.subscribe(to: key)
+ return store.object(forKey: key) as? Value ?? initialValue
+ }
+ nonmutating set {
+ store.set(newValue, forKey: key)
+ }
+ }
+}
+
+private class UserDefaultObserver: ObservableObject {
+ private var subscribed = false
+
+ func subscribe(to key: String) {
+ if !subscribed {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(userDefaultsDidChange),
+ name: UserDefaults.didChangeNotification,
+ object: nil
+ )
+ subscribed = true
+ }
+ }
+
+ @objc
+ private func userDefaultsDidChange(_ notification: Notification) {
+ Task { @MainActor in objectWillChange.send() }
+ }
+
+ deinit { NotificationCenter.default.removeObserver(self) }
+}
diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
index 5e64f7fb0d..27bb95b599 100644
--- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
+++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
@@ -65,8 +65,7 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
- m.chats = []
- m.popChatCollector.clear()
+ m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
_ = kcSelfDestructPassword.remove()
diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
index 9cc229ba80..eb8df5fb04 100644
--- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift
@@ -24,6 +24,7 @@ private enum MigrationFromState: Equatable {
}
private enum MigrateFromDeviceViewAlert: Identifiable {
+ case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
@@ -38,6 +39,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
var id: String {
switch self {
+ case .finishMigration: return "finishMigration"
case let .deleteChat(title, text): return "\(title) \(text)"
case let .startChat(title, text): return "\(title) \(text)"
@@ -56,8 +58,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
struct MigrateFromDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @Environment(\.dismiss) var dismiss: DismissAction
- @Binding var showSettings: Bool
@Binding var showProgressOnSettings: Bool
@State private var migrationState: MigrationFromState = .chatStopInProgress
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@@ -106,9 +106,6 @@ struct MigrateFromDevice: View {
finishedView(chatDeletion)
}
}
- .modifier(BackButton(label: "Back", disabled: $backDisabled) {
- dismiss()
- })
.onChange(of: migrationState) { state in
backDisabled = switch migrationState {
case .chatStopInProgress, .archiving, .linkShown, .finished: true
@@ -138,6 +135,15 @@ struct MigrateFromDevice: View {
}
.alert(item: $alert) { alert in
switch alert {
+ case let .finishMigration(fileId, ctrl):
+ return Alert(
+ title: Text("Remove archive?"),
+ message: Text("The uploaded database archive will be permanently removed from the servers."),
+ primaryButton: .destructive(Text("Continue")) {
+ finishMigration(fileId, ctrl)
+ },
+ secondaryButton: .cancel()
+ )
case let .startChat(title, text):
return Alert(
title: Text(title),
@@ -171,7 +177,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
- message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
+ message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) }
}
@@ -216,7 +222,8 @@ struct MigrateFromDevice: View {
}
private func passphraseNotSetView() -> some View {
- DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
+ DatabaseEncryptionView(useKeychain: $useKeychain, migration: true, stopChatRunBlockStartChat: { _, _ in
+ })
.onChange(of: initialRandomDBPassphrase) { initial in
if !initial {
migrationState = .uploadConfirmation
@@ -318,7 +325,7 @@ struct MigrateFromDevice: View {
Text("Cancel migration").foregroundColor(.red)
}
}
- Button(action: { finishMigration(fileId, ctrl) }) {
+ Button(action: { alert = .finishMigration(fileId, ctrl) }) {
settingsRow("checkmark", color: theme.colors.secondary) {
Text("Finalize migration").foregroundColor(theme.colors.primary)
}
@@ -523,9 +530,15 @@ struct MigrateFromDevice: View {
}
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
let cfg = getNetCfg()
+ let proxy: NetworkProxy? = if cfg.socksProxy == nil {
+ nil
+ } else {
+ networkProxyDefault.get()
+ }
let data = MigrationFileLinkData.init(
networkConfig: MigrationFileLinkData.NetworkConfig(
socksProxy: cfg.socksProxy,
+ networkProxy: proxy,
hostMode: cfg.hostMode,
requiredHostMode: cfg.requiredHostMode
)
@@ -590,7 +603,7 @@ struct MigrateFromDevice: View {
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
- showSettings = false
+ dismissAllSheets(animated: true)
}
} catch let error {
alert = .error(title: "Error deleting database", error: responseError(error))
@@ -613,9 +626,7 @@ struct MigrateFromDevice: View {
}
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if dismiss || m.chatDbStatus != .ok {
- await MainActor.run {
- showSettings = false
- }
+ dismissAllSheets(animated: true)
}
}
@@ -767,6 +778,6 @@ private class MigrationChatReceiver {
struct MigrateFromDevice_Previews: PreviewProvider {
static var previews: some View {
- MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
+ MigrateFromDevice(showProgressOnSettings: Binding.constant(false))
}
}
diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
index 67ea1008cd..763cd473fe 100644
--- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
+++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift
@@ -93,7 +93,6 @@ struct MigrateToDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
- @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert?
@@ -102,6 +101,10 @@ struct MigrateToDevice: View {
// Prevent from hiding the view until migration is finished or app deleted
@State private var backDisabled: Bool = false
@State private var showQRCodeScanner: Bool = true
+ @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
+
+ @State private var importingArchiveFromFileProgressIndicator = false
+ @State private var showFileImporter = false
var body: some View {
VStack {
@@ -197,11 +200,15 @@ struct MigrateToDevice: View {
}
}
}
- if developerTools {
- Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
- pasteLinkView()
- }
+ Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
+ pasteLinkView()
}
+ Section(header: Text("Or import archive file").foregroundColor(theme.colors.secondary)) {
+ archiveImportFromFileView()
+ }
+ }
+ if importingArchiveFromFileProgressIndicator {
+ progressView()
}
}
}
@@ -218,10 +225,38 @@ struct MigrateToDevice: View {
} label: {
Text("Tap to paste link")
}
- .disabled(!ChatModel.shared.pasteboardHasStrings)
+ .disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
}
+ private func archiveImportFromFileView() -> some View {
+ Button {
+ showFileImporter = true
+ } label: {
+ Label("Import database", systemImage: "square.and.arrow.down")
+ }
+ .disabled(importingArchiveFromFileProgressIndicator)
+ .fileImporter(
+ isPresented: $showFileImporter,
+ allowedContentTypes: [.zip],
+ allowsMultipleSelection: false
+ ) { result in
+ if case let .success(files) = result, let fileURL = files.first {
+ Task {
+ let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil))
+ if success {
+ DatabaseView.startChat(
+ Binding.constant(false),
+ $importingArchiveFromFileProgressIndicator
+ )
+ hideView()
+ }
+ }
+ }
+ }
+ }
+
+
private func linkDownloadingView(_ link: String) -> some View {
ZStack {
List {
@@ -487,6 +522,9 @@ struct MigrateToDevice: View {
do {
if !hasChatCtrl() {
chatInitControllerRemovingDatabases()
+ } else if ChatModel.shared.chatRunning == true {
+ // cannot delete storage if chat is running
+ try await apiStopChat()
}
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@@ -556,11 +594,22 @@ struct MigrateToDevice: View {
do {
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
MigrationToDeviceState.save(nil)
- appSettings.importIntoApp()
- try SimpleX.startChat(refreshInvitations: true)
- AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
+ try ObjC.catchException {
+ appSettings.importIntoApp()
+ }
+ do {
+ try SimpleX.startChat(refreshInvitations: true)
+ AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
+ } catch let error {
+ AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
+ }
} catch let error {
- AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
+ logger.error("Error importing settings: \(error.localizedDescription)")
+ AlertManager.shared.showAlert(
+ Alert(
+ title: Text("Error migrating settings"),
+ message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
+ )
}
hideView()
}
diff --git a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
index 6001dff790..3a64a955c5 100644
--- a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
+++ b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
@@ -28,7 +28,9 @@ struct AddContactLearnMore: View {
Text("If you can't meet in person, show QR code in a video call, or share the link.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
}
+ .frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.modifier(ThemedBackground(grouped: true))
}
diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
index 113691abb3..0c7f6136ff 100644
--- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
@@ -59,7 +59,7 @@ struct AddGroupView: View {
.navigationBarTitle("Group link")
}
} else {
- createGroupView().keyboardPadding()
+ createGroupView()
}
}
@@ -137,10 +137,13 @@ struct AddGroupView: View {
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
}
.onChange(of: chosenImage) { image in
- if let image = image {
- profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
- } else {
- profile.image = nil
+ Task {
+ let resized: String? = if let image {
+ await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
+ } else {
+ nil
+ }
+ await MainActor.run { profile.image = resized }
}
}
.modifier(ThemedBackground(grouped: true))
diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
index bcca763a75..6f973983bf 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
@@ -14,9 +14,10 @@ enum ContactType: Int {
}
struct NewChatMenuButton: View {
+ // do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16
+// @EnvironmentObject var chatModel: ChatModel
@State private var showNewChatSheet = false
@State private var alert: SomeAlert? = nil
- @State private var globalAlert: SomeAlert? = nil
var body: some View {
Button {
@@ -28,22 +29,10 @@ struct NewChatMenuButton: View {
.frame(width: 24, height: 24)
}
.appSheet(isPresented: $showNewChatSheet) {
- NewChatSheet(alert: $alert)
+ NewChatSheet()
.environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
- .alert(item: $alert) { a in
- return a.alert
- }
}
- // This is a workaround to show "Keep unused invitation" alert in both following cases:
- // - on going back from NewChatView to NewChatSheet,
- // - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet)
- .onChange(of: alert?.id) { a in
- if !showNewChatSheet && alert != nil {
- globalAlert = alert
- alert = nil
- }
- }
- .alert(item: $globalAlert) { a in
+ .alert(item: $alert) { a in
return a.alert
}
}
@@ -60,7 +49,7 @@ struct NewChatSheet: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
- @Binding var alert: SomeAlert?
+ @State private var alert: SomeAlert?
// Sheet height management
@State private var isAddContactActive = false
@@ -78,6 +67,9 @@ struct NewChatSheet: View {
.navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true))
+ .alert(item: $alert) { a in
+ return a.alert
+ }
}
if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500
@@ -112,17 +104,17 @@ struct NewChatSheet: View {
if (searchText.isEmpty) {
Section {
NavigationLink(isActive: $isAddContactActive) {
- NewChatView(selection: .invite, parentAlert: $alert)
+ NewChatView(selection: .invite)
.navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
- navigateOnTap(Label("Add contact", systemImage: "link.badge.plus")) {
+ navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) {
isAddContactActive = true
}
}
NavigationLink(isActive: $isScanPasteLinkActive) {
- NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert)
+ NewChatView(selection: .connect, showQRCodeScanner: true)
.navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
@@ -199,7 +191,7 @@ func chatContactType(chat: Chat) -> ContactType {
case .contactRequest:
return .request
case let .direct(contact):
- if contact.activeConn == nil && contact.profile.contactLink != nil {
+ if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
return .card
} else if contact.chatDeleted {
return .chatDeleted
diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift
index 6cbc65e7c9..e18d932278 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatView.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift
@@ -45,29 +45,63 @@ enum NewChatOption: Identifiable {
var id: Self { self }
}
+func showKeepInvitationAlert() {
+ if let showingInvitation = ChatModel.shared.showingInvitation,
+ !showingInvitation.connChatUsed {
+ showAlert(
+ NSLocalizedString("Keep unused invitation?", comment: "alert title"),
+ message: NSLocalizedString("You can view invitation link again in connection details.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Keep", comment: "alert action"),
+ style: .default
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Delete", comment: "alert action"),
+ style: .destructive,
+ handler: { _ in
+ Task {
+ await deleteChat(Chat(
+ chatInfo: .contactConnection(contactConnection: showingInvitation.pcc),
+ chatItems: []
+ ))
+ }
+ }
+ )
+ ]}
+ )
+ }
+ ChatModel.shared.showingInvitation = nil
+}
+
struct NewChatView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State var selection: NewChatOption
@State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false
- @State private var contactConnection: PendingContactConnection? = nil
@State private var connReqInvitation: String = ""
@State private var creatingConnReq = false
+ @State var choosingProfile = false
@State private var pastedLink: String = ""
@State private var alert: NewChatViewAlert?
- @Binding var parentAlert: SomeAlert?
+ @State private var contactConnection: PendingContactConnection? = nil
var body: some View {
VStack(alignment: .leading) {
Picker("New chat", selection: $selection) {
- Label("Add contact", systemImage: "link")
+ Label("1-time link", systemImage: "link")
.tag(NewChatOption.invite)
Label("Connect via link", systemImage: "qrcode")
.tag(NewChatOption.connect)
}
.pickerStyle(.segmented)
.padding()
+ .onChange(of: $selection.wrappedValue) { opt in
+ if opt == NewChatOption.connect {
+ showQRCodeScanner = true
+ }
+ }
VStack {
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
@@ -122,26 +156,10 @@ struct NewChatView: View {
}
}
.onDisappear {
- if !(m.showingInvitation?.connChatUsed ?? true),
- let conn = contactConnection {
- parentAlert = SomeAlert(
- alert: Alert(
- title: Text("Keep unused invitation?"),
- message: Text("You can view invitation link again in connection details."),
- primaryButton: .default(Text("Keep")) {},
- secondaryButton: .destructive(Text("Delete")) {
- Task {
- await deleteChat(Chat(
- chatInfo: .contactConnection(contactConnection: conn),
- chatItems: []
- ))
- }
- }
- ),
- id: "keepUnusedInvitation"
- )
+ if !choosingProfile {
+ showKeepInvitationAlert()
+ contactConnection = nil
}
- m.showingInvitation = nil
}
.alert(item: $alert) { a in
switch(a) {
@@ -159,7 +177,8 @@ struct NewChatView: View {
InviteView(
invitationUsed: $invitationUsed,
contactConnection: $contactConnection,
- connReqInvitation: connReqInvitation
+ connReqInvitation: $connReqInvitation,
+ choosingProfile: $choosingProfile
)
} else if creatingConnReq {
creatingLinkProgressView()
@@ -178,7 +197,7 @@ struct NewChatView: View {
if let (connReq, pcc) = r {
await MainActor.run {
m.updateContactConnection(pcc)
- m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false)
+ m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
connReqInvitation = connReq
contactConnection = pcc
}
@@ -210,12 +229,22 @@ struct NewChatView: View {
}
}
+private func incognitoProfileImage() -> some View {
+ Image(systemName: "theatermasks.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 30)
+ .foregroundColor(.indigo)
+}
+
private struct InviteView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection?
- var connReqInvitation: String
+ @Binding var connReqInvitation: String
+ @Binding var choosingProfile: Bool
+
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
@@ -226,28 +255,40 @@ private struct InviteView: View {
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView()
-
- Section {
- IncognitoToggle(incognitoEnabled: $incognitoDefault)
- } footer: {
- sharedProfileInfo(incognitoDefault)
- .foregroundColor(theme.colors.secondary)
+ if let selectedProfile = chatModel.currentUser {
+ Section {
+ NavigationLink {
+ ActiveProfilePicker(
+ contactConnection: $contactConnection,
+ connReqInvitation: $connReqInvitation,
+ incognitoEnabled: $incognitoDefault,
+ choosingProfile: $choosingProfile,
+ selectedProfile: selectedProfile
+ )
+ } label: {
+ HStack {
+ if incognitoDefault {
+ incognitoProfileImage()
+ Text("Incognito")
+ } else {
+ ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
+ Text(chatModel.currentUser?.chatViewName ?? "")
+ }
+ }
+ }
+ } header: {
+ Text("Share profile").foregroundColor(theme.colors.secondary)
+ } footer: {
+ if incognitoDefault {
+ Text("A new random profile will be shared.")
+ }
+ }
}
}
.onChange(of: incognitoDefault) { incognito in
- Task {
- do {
- if let contactConn = contactConnection,
- let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
- await MainActor.run {
- contactConnection = conn
- chatModel.updateContactConnection(conn)
- }
- }
- } catch {
- logger.error("apiSetConnectionIncognito error: \(responseError(error))")
- }
- }
+ setInvitationUsed()
+ }
+ .onChange(of: chatModel.currentUser) { u in
setInvitationUsed()
}
}
@@ -270,6 +311,7 @@ private struct InviteView: View {
private func qrCodeView() -> some View {
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
+ .id("simplex-qrcode-view-for-\(connReqInvitation)")
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -289,6 +331,257 @@ private struct InviteView: View {
}
}
+private enum ProfileSwitchStatus {
+ case switchingUser
+ case switchingIncognito
+ case idle
+}
+
+private struct ActiveProfilePicker: View {
+ @Environment(\.dismiss) var dismiss
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Binding var contactConnection: PendingContactConnection?
+ @Binding var connReqInvitation: String
+ @Binding var incognitoEnabled: Bool
+ @Binding var choosingProfile: Bool
+ @State private var alert: SomeAlert?
+ @State private var profileSwitchStatus: ProfileSwitchStatus = .idle
+ @State private var switchingProfileByTimeout = false
+ @State private var lastSwitchingProfileByTimeoutCall: Double?
+ @State private var profiles: [User] = []
+ @State private var searchTextOrPassword = ""
+ @State private var showIncognitoSheet = false
+ @State private var incognitoFirst: Bool = false
+ @State var selectedProfile: User
+ var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
+
+ var body: some View {
+ viewBody()
+ .navigationTitle("Select chat profile")
+ .searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
+ .autocorrectionDisabled(true)
+ .navigationBarTitleDisplayMode(.large)
+ .onAppear {
+ profiles = chatModel.users
+ .map { $0.user }
+ .sorted { u, _ in u.activeUser }
+ }
+ .onChange(of: incognitoEnabled) { incognito in
+ if profileSwitchStatus != .switchingIncognito {
+ return
+ }
+
+ Task {
+ do {
+ if let contactConn = contactConnection,
+ let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
+ await MainActor.run {
+ contactConnection = conn
+ chatModel.updateContactConnection(conn)
+ profileSwitchStatus = .idle
+ dismiss()
+ }
+ }
+ } catch {
+ profileSwitchStatus = .idle
+ incognitoEnabled = !incognito
+ logger.error("apiSetConnectionIncognito error: \(responseError(error))")
+ let err = getErrorAlert(error, "Error changing to incognito!")
+
+ alert = SomeAlert(
+ alert: Alert(
+ title: Text(err.title),
+ message: Text(err.message ?? "Error: \(responseError(error))")
+ ),
+ id: "setConnectionIncognitoError"
+ )
+ }
+ }
+ }
+ .onChange(of: profileSwitchStatus) { sp in
+ if sp != .idle {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ switchingProfileByTimeout = profileSwitchStatus != .idle
+ }
+ } else {
+ switchingProfileByTimeout = false
+ }
+ }
+ .onChange(of: selectedProfile) { profile in
+ if (profileSwitchStatus != .switchingUser) {
+ return
+ }
+ Task {
+ do {
+ if let contactConn = contactConnection,
+ let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
+
+ await MainActor.run {
+ contactConnection = conn
+ connReqInvitation = conn.connReqInv ?? ""
+ incognitoEnabled = false
+ chatModel.updateContactConnection(conn)
+ }
+ do {
+ try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
+ await MainActor.run {
+ profileSwitchStatus = .idle
+ dismiss()
+ }
+ } catch {
+ await MainActor.run {
+ profileSwitchStatus = .idle
+ alert = SomeAlert(
+ alert: Alert(
+ title: Text("Error switching profile"),
+ message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
+ ),
+ id: "switchingProfileError"
+ )
+ }
+ }
+ }
+ } catch {
+ await MainActor.run {
+ profileSwitchStatus = .idle
+ if let currentUser = chatModel.currentUser {
+ selectedProfile = currentUser
+ }
+ let err = getErrorAlert(error, "Error changing connection profile")
+ alert = SomeAlert(
+ alert: Alert(
+ title: Text(err.title),
+ message: Text(err.message ?? "Error: \(responseError(error))")
+ ),
+ id: "changeConnectionUserError"
+ )
+ }
+ }
+ }
+ }
+ .alert(item: $alert) { a in
+ a.alert
+ }
+ .onAppear {
+ incognitoFirst = incognitoEnabled
+ choosingProfile = true
+ }
+ .onDisappear {
+ choosingProfile = false
+ }
+ .sheet(isPresented: $showIncognitoSheet) {
+ IncognitoHelp()
+ }
+ }
+
+
+ @ViewBuilder private func viewBody() -> some View {
+ profilePicker()
+ .allowsHitTesting(!switchingProfileByTimeout)
+ .modifier(ThemedBackground(grouped: true))
+ .overlay {
+ if switchingProfileByTimeout {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+
+ private func filteredProfiles() -> [User] {
+ let s = trimmedSearchTextOrPassword
+ let lower = s.localizedLowercase
+
+ return profiles.filter { u in
+ if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
+ return true
+ }
+ return correctPassword(u, s)
+ }
+ }
+
+ @ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
+ Button {
+ if selectedProfile == user && incognitoEnabled {
+ incognitoEnabled = false
+ profileSwitchStatus = .switchingIncognito
+ } else if selectedProfile != user {
+ selectedProfile = user
+ profileSwitchStatus = .switchingUser
+ }
+ } label: {
+ HStack {
+ ProfileImage(imageStr: user.image, size: 30)
+ .padding(.trailing, 2)
+ Text(user.chatViewName)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ Spacer()
+ if selectedProfile == user, !incognitoEnabled {
+ Image(systemName: "checkmark")
+ .resizable().scaledToFit().frame(width: 16)
+ .foregroundColor(theme.colors.primary)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func profilePicker() -> some View {
+ let incognitoOption = Button {
+ if !incognitoEnabled {
+ incognitoEnabled = true
+ profileSwitchStatus = .switchingIncognito
+ }
+ } label : {
+ HStack {
+ incognitoProfileImage()
+ Text("Incognito")
+ .foregroundColor(theme.colors.onBackground)
+ Image(systemName: "info.circle")
+ .foregroundColor(theme.colors.primary)
+ .font(.system(size: 14))
+ .onTapGesture {
+ showIncognitoSheet = true
+ }
+ Spacer()
+ if incognitoEnabled {
+ Image(systemName: "checkmark")
+ .resizable().scaledToFit().frame(width: 16)
+ .foregroundColor(theme.colors.primary)
+ }
+ }
+ }
+
+ List {
+ let filteredProfiles = filteredProfiles()
+ let activeProfile = filteredProfiles.first { u in u.activeUser }
+
+ if let selectedProfile = activeProfile {
+ let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
+
+ if incognitoFirst {
+ incognitoOption
+ profilerPickerUserOption(selectedProfile)
+ } else {
+ profilerPickerUserOption(selectedProfile)
+ incognitoOption
+ }
+
+ ForEach(otherProfiles) { p in
+ profilerPickerUserOption(p)
+ }
+ } else {
+ incognitoOption
+ ForEach(filteredProfiles) { p in
+ profilerPickerUserOption(p)
+ }
+ }
+ }
+ .opacity(switchingProfileByTimeout ? 0.4 : 1)
+ }
+}
+
private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@@ -296,6 +589,7 @@ private struct ConnectView: View {
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
+ @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
List {
@@ -332,7 +626,7 @@ private struct ConnectView: View {
} label: {
Text("Tap to paste link")
}
- .disabled(!ChatModel.shared.pasteboardHasStrings)
+ .disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
} else {
linkTextView(pastedLink)
@@ -403,6 +697,7 @@ struct ScannerInView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(Color.clear)
switch cameraAuthorizationStatus {
+ case .authorized, nil: EmptyView()
case .restricted: Text("Camera not available")
case .denied: Label("Enable camera access", systemImage: "camera")
default: Label("Tap to scan", systemImage: "qrcode")
@@ -422,21 +717,26 @@ struct ScannerInView: View {
.disabled(cameraAuthorizationStatus == .restricted)
}
}
- .onAppear {
+ .task {
let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status
if showQRCodeScanner {
switch status {
- case .notDetermined: askCameraAuthorization()
+ case .notDetermined: await askCameraAuthorizationAsync()
case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false
case .authorized: ()
- @unknown default: askCameraAuthorization()
+ @unknown default: await askCameraAuthorizationAsync()
}
}
}
}
+ func askCameraAuthorizationAsync() async {
+ await AVCaptureDevice.requestAccess(for: .video)
+ cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
+ }
+
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
@@ -616,11 +916,17 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (
)
case let .groupLinkConnecting(_, groupInfo):
if let groupInfo = groupInfo {
- return Alert(
+ return groupInfo.businessChat == nil
+ ? Alert(
title: Text("Group already exists!"),
message: Text("You are already joining the group \(groupInfo.displayName)."),
dismissButton: .default(Text("OK")) { cleanup?() }
)
+ : Alert(
+ title: Text("Chat already exists!"),
+ message: Text("You are already connecting to \(groupInfo.displayName)."),
+ dismissButton: .default(Text("OK")) { cleanup?() }
+ )
} else {
return Alert(
title: Text("Already joining the group!"),
@@ -937,10 +1243,15 @@ func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
}
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
- mkAlert(
+ groupInfo.businessChat == nil
+ ? mkAlert(
title: "Group already exists",
message: "You are already in group \(groupInfo.displayName)."
)
+ : mkAlert(
+ title: "Chat already exists",
+ message: "You are already connected with \(groupInfo.displayName)."
+ )
}
enum ConnReqType: Equatable {
@@ -975,10 +1286,10 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
struct NewChatView_Previews: PreviewProvider {
static var previews: some View {
@State var parentAlert: SomeAlert?
+ @State var contactConnection: PendingContactConnection? = nil
NewChatView(
- selection: .invite,
- parentAlert: $parentAlert
+ selection: .invite
)
}
}
diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift
index e3bae9287a..bc1dc4b5bc 100644
--- a/apps/ios/Shared/Views/NewChat/QRCode.swift
+++ b/apps/ios/Shared/Views/NewChat/QRCode.swift
@@ -49,34 +49,34 @@ struct QRCode: View {
ZStack {
if let image = image {
qrCodeImage(image)
- }
- GeometryReader { geo in
- ZStack {
- if withLogo {
- let w = geo.size.width
- Image("icon-light")
- .resizable()
- .scaledToFit()
- .frame(width: w * 0.16, height: w * 0.16)
- .frame(width: w * 0.165, height: w * 0.165)
- .background(.white)
- .clipShape(Circle())
+ GeometryReader { geo in
+ ZStack {
+ if withLogo {
+ let w = geo.size.width
+ Image("icon-light")
+ .resizable()
+ .scaledToFit()
+ .frame(width: w * 0.16, height: w * 0.16)
+ .frame(width: w * 0.165, height: w * 0.165)
+ .background(.white)
+ .clipShape(Circle())
+ }
}
- }
- .onAppear {
- makeScreenshotFunc = {
- let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
- showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
- onShare?()
+ .onAppear {
+ makeScreenshotFunc = {
+ let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
+ showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
+ onShare?()
+ }
}
+ .frame(width: geo.size.width, height: geo.size.height)
}
- .frame(width: geo.size.width, height: geo.size.height)
+ } else {
+ Color.clear.aspectRatio(1, contentMode: .fit)
}
}
.onTapGesture(perform: makeScreenshotFunc)
- .onAppear {
- image = image ?? generateImage(uri, tintColor: tintColor)
- }
+ .task { image = await generateImage(uri, tintColor: tintColor) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -89,7 +89,7 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled)
}
-private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
+private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift
new file mode 100644
index 0000000000..2069ca9487
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift
@@ -0,0 +1,109 @@
+//
+// AddressCreationCard.swift
+// SimpleX (iOS)
+//
+// Created by Diogo Cunha on 13/11/2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct AddressCreationCard: View {
+ @EnvironmentObject var theme: AppTheme
+ @EnvironmentObject private var chatModel: ChatModel
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
+ @State private var showAddressCreationAlert = false
+ @State private var showAddressSheet = false
+ @State private var showAddressInfoSheet = false
+
+ var body: some View {
+ let addressExists = chatModel.userAddress != nil
+ let chats = chatModel.chats.filter { chat in
+ !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
+ }
+ ZStack(alignment: .topTrailing) {
+ HStack(alignment: .top, spacing: 16) {
+ let envelopeSize = dynamicSize(userFont).profileImageSize
+ Image(systemName: "envelope.circle.fill")
+ .resizable()
+ .frame(width: envelopeSize, height: envelopeSize)
+ .foregroundColor(.accentColor)
+ VStack(alignment: .leading) {
+ Text("Your SimpleX address")
+ .font(.title3)
+ Spacer()
+ Text("How to use it") + textSpace + Text(Image(systemName: "info.circle")).foregroundColor(theme.colors.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ VStack(alignment: .trailing) {
+ Image(systemName: "multiply")
+ .foregroundColor(theme.colors.secondary)
+ .onTapGesture {
+ showAddressCreationAlert = true
+ }
+ Spacer()
+ Text("Create")
+ .foregroundColor(.accentColor)
+ .onTapGesture {
+ showAddressSheet = true
+ }
+ }
+ }
+ .onTapGesture {
+ showAddressInfoSheet = true
+ }
+ .padding()
+ .background(theme.appColors.sentMessage)
+ .cornerRadius(12)
+ .frame(height: dynamicSize(userFont).rowHeight)
+ .alert(isPresented: $showAddressCreationAlert) {
+ Alert(
+ title: Text("SimpleX address"),
+ message: Text("Tap Create SimpleX address in the menu to create it later."),
+ dismissButton: .default(Text("Ok")) {
+ withAnimation {
+ addressCreationCardShown = true
+ }
+ }
+ )
+ }
+ .sheet(isPresented: $showAddressSheet) {
+ NavigationView {
+ UserAddressView(autoCreate: true)
+ .navigationTitle("SimpleX address")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ .sheet(isPresented: $showAddressInfoSheet) {
+ NavigationView {
+ UserAddressLearnMore(showCreateAddressButton: true)
+ .navigationTitle("Address or 1-time link?")
+ .navigationBarTitleDisplayMode(.inline)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ .onChange(of: addressExists) { exists in
+ if exists, !addressCreationCardShown {
+ addressCreationCardShown = true
+ }
+ }
+ .onChange(of: chats.count) { size in
+ if size >= 3, !addressCreationCardShown {
+ addressCreationCardShown = true
+ }
+ }
+ .onAppear {
+ if addressExists, !addressCreationCardShown {
+ addressCreationCardShown = true
+ }
+ }
+ }
+}
+
+#Preview {
+ AddressCreationCard()
+}
diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
new file mode 100644
index 0000000000..1a0a736acd
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
@@ -0,0 +1,451 @@
+//
+// ChooseServerOperators.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 31.10.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")!
+
+struct OnboardingButtonStyle: ButtonStyle {
+ @EnvironmentObject var theme: AppTheme
+ var isDisabled: Bool = false
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: 17, weight: .semibold))
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(
+ isDisabled
+ ? (
+ theme.colors.isLight
+ ? .gray.opacity(0.17)
+ : .gray.opacity(0.27)
+ )
+ : theme.colors.primary
+ )
+ .foregroundColor(
+ isDisabled
+ ? (
+ theme.colors.isLight
+ ? .gray.opacity(0.4)
+ : .white.opacity(0.2)
+ )
+ : .white
+ )
+ .cornerRadius(16)
+ .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
+ }
+}
+
+private enum ChooseServerOperatorsSheet: Identifiable {
+ case showInfo
+ case showConditions
+
+ var id: String {
+ switch self {
+ case .showInfo: return "showInfo"
+ case .showConditions: return "showConditions"
+ }
+ }
+}
+
+struct ChooseServerOperators: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+ var onboarding: Bool
+ @State private var serverOperators: [ServerOperator] = []
+ @State private var selectedOperatorIds = Set()
+ @State private var sheetItem: ChooseServerOperatorsSheet? = nil
+ @State private var notificationsModeNavLinkActive = false
+ @State private var justOpened = true
+
+ var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } }
+
+ var body: some View {
+ GeometryReader { g in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ let title = Text("Server operators")
+ .font(.largeTitle)
+ .bold()
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ if onboarding {
+ title.padding(.top, 25)
+ } else {
+ title
+ }
+
+ infoText()
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Spacer()
+
+ ForEach(serverOperators) { srvOperator in
+ operatorCheckView(srvOperator)
+ }
+ VStack {
+ Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
+ Text("You can configure servers via settings.")
+ }
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 16)
+
+ Spacer()
+
+ let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
+ let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
+ let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
+
+ VStack(spacing: 8) {
+ if !reviewForOperators.isEmpty {
+ reviewConditionsButton()
+ } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
+ setOperatorsButton()
+ } else {
+ continueButton()
+ }
+ if onboarding {
+ Group {
+ if reviewForOperators.isEmpty {
+ Button("Conditions of use") {
+ sheetItem = .showConditions
+ }
+ } else {
+ Text("Conditions of use")
+ .foregroundColor(.clear)
+ }
+ }
+ .font(.system(size: 17, weight: .semibold))
+ .frame(minHeight: 40)
+ }
+ }
+
+ if !onboarding && !reviewForOperators.isEmpty {
+ VStack(spacing: 8) {
+ reviewLaterButton()
+ (
+ Text("Conditions will be accepted for enabled operators after 30 days.")
+ + textSpace
+ + Text("You can configure operators in Network & servers settings.")
+ )
+ .multilineTextAlignment(.center)
+ .font(.footnote)
+ .padding(.horizontal, 32)
+ }
+ .frame(maxWidth: .infinity)
+ .disabled(!canReviewLater)
+ .padding(.bottom)
+ }
+ }
+ .frame(minHeight: g.size.height)
+ }
+ .onAppear {
+ if justOpened {
+ serverOperators = ChatModel.shared.conditions.serverOperators
+ selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
+ justOpened = false
+ }
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showInfo:
+ ChooseServerOperatorsInfoView()
+ case .showConditions:
+ UsageConditionsView(
+ currUserServers: Binding.constant([]),
+ userServers: Binding.constant([])
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ .padding(onboarding ? 25 : 16)
+ }
+
+ private func infoText() -> some View {
+ Button {
+ sheetItem = .showInfo
+ } label: {
+ Label("How it helps privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
+
+ @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
+ let checked = selectedOperatorIds.contains(serverOperator.operatorId)
+ let icon = checked ? "checkmark.circle.fill" : "circle"
+ let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
+ HStack(spacing: 10) {
+ Image(serverOperator.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ Spacer()
+ Image(systemName: icon)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 26, height: 26)
+ .foregroundColor(iconColor)
+ }
+ .background(theme.colors.background)
+ .padding()
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18)
+ .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
+ )
+ .padding(.horizontal, 2)
+ .onTapGesture {
+ if checked {
+ selectedOperatorIds.remove(serverOperator.operatorId)
+ } else {
+ selectedOperatorIds.insert(serverOperator.operatorId)
+ }
+ }
+ }
+
+ private func reviewConditionsButton() -> some View {
+ NavigationLink("Review conditions") {
+ reviewConditionsView()
+ .navigationTitle("Conditions of use")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+
+ private func setOperatorsButton() -> some View {
+ notificationsModeNavLinkButton {
+ Button {
+ Task {
+ if let enabledOperators = enabledOperators(serverOperators) {
+ let r = try await setServerOperators(operators: enabledOperators)
+ await MainActor.run {
+ ChatModel.shared.conditions = r
+ continueToNextStep()
+ }
+ } else {
+ await MainActor.run {
+ continueToNextStep()
+ }
+ }
+ }
+ } label: {
+ Text("Update")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+ }
+
+ private func continueButton() -> some View {
+ notificationsModeNavLinkButton {
+ Button {
+ continueToNextStep()
+ } label: {
+ Text("Continue")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+ }
+
+ private func reviewLaterButton() -> some View {
+ notificationsModeNavLinkButton {
+ Button {
+ continueToNextStep()
+ } label: {
+ Text("Review later")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+
+ private func continueToNextStep() {
+ if onboarding {
+ onboardingStageDefault.set(.step4_SetNotificationsMode)
+ notificationsModeNavLinkActive = true
+ } else {
+ dismiss()
+ }
+ }
+
+ func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
+ ZStack {
+ button()
+
+ NavigationLink(isActive: $notificationsModeNavLinkActive) {
+ notificationsModeDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ }
+
+ private func notificationsModeDestinationView() -> some View {
+ SetNotificationsMode()
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ }
+
+ @ViewBuilder private func reviewConditionsView() -> some View {
+ let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
+ let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
+ VStack(alignment: .leading, spacing: 20) {
+ if !operatorsWithConditionsAccepted.isEmpty {
+ Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
+ Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ } else {
+ Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ }
+ ConditionsTextView()
+ .frame(maxHeight: .infinity)
+ acceptConditionsButton()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ .padding(.horizontal, 25)
+ }
+
+ private func acceptConditionsButton() -> some View {
+ notificationsModeNavLinkButton {
+ Button {
+ Task {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
+ let operatorIds = acceptForOperators.map { $0.operatorId }
+ let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
+ await MainActor.run {
+ ChatModel.shared.conditions = r
+ }
+ if let enabledOperators = enabledOperators(r.serverOperators) {
+ let r2 = try await setServerOperators(operators: enabledOperators)
+ await MainActor.run {
+ ChatModel.shared.conditions = r2
+ continueToNextStep()
+ }
+ } else {
+ await MainActor.run {
+ continueToNextStep()
+ }
+ }
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting conditions", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ } label: {
+ Text("Accept conditions")
+ }
+ .buttonStyle(OnboardingButtonStyle())
+ }
+ }
+
+ private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? {
+ var ops = operators
+ if !ops.isEmpty {
+ for i in 0.. some View {
+ NavigationLink() {
+ OperatorInfoView(serverOperator: op)
+ .navigationBarTitle("Network operator")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Image(op.logo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ Text(op.tradeName)
+ }
+ }
+ }
+}
+
+#Preview {
+ ChooseServerOperators(onboarding: true)
+}
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
index a62f609833..14ad9dfb08 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -38,7 +38,7 @@ struct CreateProfile: View {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
Button {
- createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
+ createProfile()
} label: {
Label("Create profile", systemImage: "checkmark")
}
@@ -77,7 +77,35 @@ struct CreateProfile: View {
focusDisplayName = true
}
}
- .keyboardPadding()
+ }
+
+ private func createProfile() {
+ hideKeyboard()
+ let profile = Profile(
+ displayName: displayName.trimmingCharacters(in: .whitespaces),
+ fullName: ""
+ )
+ let m = ChatModel.shared
+ do {
+ AppChatState.shared.set(.active)
+ m.currentUser = try apiCreateActiveUser(profile)
+ // .isEmpty check is redundant here, but it makes it clearer what is going on
+ if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
+ try startChat()
+ withAnimation {
+ onboardingStageDefault.set(.step3_ChooseServerOperators)
+ m.onboardingStage = .step3_ChooseServerOperators
+ }
+ } else {
+ onboardingStageDefault.set(.onboardingComplete)
+ m.onboardingStage = .onboardingComplete
+ dismiss()
+ m.users = try listUsers()
+ try getUserChatData()
+ }
+ } catch let error {
+ showCreateProfileAlert(showAlert: { alert = $0 }, error)
+ }
}
}
@@ -87,132 +115,150 @@ struct CreateFirstProfile: View {
@Environment(\.dismiss) var dismiss
@State private var displayName: String = ""
@FocusState private var focusDisplayName
+ @State private var nextStepNavLinkActive = false
var body: some View {
- VStack(alignment: .leading) {
- Group {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .center, spacing: 20) {
Text("Create your profile")
.font(.largeTitle)
.bold()
+ .multilineTextAlignment(.center)
+
Text("Your profile, contacts and delivered messages are stored on your device.")
+ .font(.callout)
.foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+
Text("The profile is only shared with your contacts.")
+ .font(.callout)
.foregroundColor(theme.colors.secondary)
- .padding(.bottom)
+ .multilineTextAlignment(.center)
}
- .padding(.bottom)
+ .frame(maxWidth: .infinity) // Ensures it takes up the full width
+ .padding(.horizontal, 10)
- ZStack(alignment: .topLeading) {
+ HStack {
let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name)
- if name != validName {
- Button {
- showAlert(.invalidNameError(validName: validName))
- } label: {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ ZStack(alignment: .trailing) {
+ TextField("Enter your name…", text: $displayName)
+ .focused($focusDisplayName)
+ .padding(.horizontal)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(uiColor: .tertiarySystemFill))
+ )
+ if name != validName {
+ Button {
+ showAlert(.invalidNameError(validName: validName))
+ } label: {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .padding(.horizontal, 10)
+ }
}
- } else {
- Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
- TextField("Enter your name…", text: $displayName)
- .focused($focusDisplayName)
- .padding(.leading, 32)
}
- .padding(.bottom)
+ .padding(.top)
+
Spacer()
- onboardingButtons()
+
+ VStack(spacing: 10) {
+ createProfileButton()
+ if !focusDisplayName {
+ onboardingButtonPlaceholder()
+ }
+ }
}
.onAppear() {
focusDisplayName = true
setLastVersionDefault()
}
- .padding()
+ .padding(.horizontal, 25)
+ .padding(.top, 10)
+ .padding(.bottom, 25)
.frame(maxWidth: .infinity, alignment: .leading)
- .keyboardPadding()
}
- func onboardingButtons() -> some View {
- HStack {
+ func createProfileButton() -> some View {
+ ZStack {
Button {
- hideKeyboard()
- withAnimation {
- m.onboardingStage = .step1_SimpleXInfo
- }
+ createProfile()
} label: {
- HStack {
- Image(systemName: "lessthan")
- Text("About SimpleX")
- }
- }
-
- Spacer()
-
- Button {
- createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
- } label: {
- HStack {
- Text("Create")
- Image(systemName: "greaterthan")
- }
+ Text("Create profile")
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
.disabled(!canCreateProfile(displayName))
+
+ NavigationLink(isActive: $nextStepNavLinkActive) {
+ nextStepDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
}
private func showAlert(_ alert: UserProfileAlert) {
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
}
+
+ private func nextStepDestinationView() -> some View {
+ ChooseServerOperators(onboarding: true)
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ }
+
+ private func createProfile() {
+ hideKeyboard()
+ let profile = Profile(
+ displayName: displayName.trimmingCharacters(in: .whitespaces),
+ fullName: ""
+ )
+ let m = ChatModel.shared
+ do {
+ AppChatState.shared.set(.active)
+ m.currentUser = try apiCreateActiveUser(profile)
+ try startChat(onboarding: true)
+ onboardingStageDefault.set(.step3_ChooseServerOperators)
+ nextStepNavLinkActive = true
+ } catch let error {
+ showCreateProfileAlert(showAlert: showAlert, error)
+ }
+ }
}
-private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
- hideKeyboard()
- let profile = Profile(
- displayName: displayName.trimmingCharacters(in: .whitespaces),
- fullName: ""
- )
+private func showCreateProfileAlert(
+ showAlert: (UserProfileAlert) -> Void,
+ _ error: Error
+) {
let m = ChatModel.shared
- do {
- AppChatState.shared.set(.active)
- m.currentUser = try apiCreateActiveUser(profile)
- // .isEmpty check is redundant here, but it makes it clearer what is going on
- if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
- try startChat()
- withAnimation {
- onboardingStageDefault.set(.step3_CreateSimpleXAddress)
- m.onboardingStage = .step3_CreateSimpleXAddress
- }
+ switch error as? ChatResponse {
+ case .chatCmdError(_, .errorStore(.duplicateName)),
+ .chatCmdError(_, .error(.userExists)):
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(duplicateUserAlert)
} else {
- onboardingStageDefault.set(.onboardingComplete)
- m.onboardingStage = .onboardingComplete
- dismiss()
- m.users = try listUsers()
- try getUserChatData()
+ showAlert(.duplicateUserError)
}
- } catch let error {
- switch error as? ChatResponse {
- case .chatCmdError(_, .errorStore(.duplicateName)),
- .chatCmdError(_, .error(.userExists)):
- if m.currentUser == nil {
- AlertManager.shared.showAlert(duplicateUserAlert)
- } else {
- showAlert(.duplicateUserError)
- }
- case .chatCmdError(_, .error(.invalidDisplayName)):
- if m.currentUser == nil {
- AlertManager.shared.showAlert(invalidDisplayNameAlert)
- } else {
- showAlert(.invalidDisplayNameError)
- }
- default:
- let err: LocalizedStringKey = "Error: \(responseError(error))"
- if m.currentUser == nil {
- AlertManager.shared.showAlert(creatUserErrorAlert(err))
- } else {
- showAlert(.createUserError(error: err))
- }
+ case .chatCmdError(_, .error(.invalidDisplayName)):
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(invalidDisplayNameAlert)
+ } else {
+ showAlert(.invalidDisplayNameError)
+ }
+ default:
+ let err: LocalizedStringKey = "Error: \(responseError(error))"
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(creatUserErrorAlert(err))
+ } else {
+ showAlert(.createUserError(error: err))
}
- logger.error("Failed to create user or start chat: \(responseError(error))")
}
+ logger.error("Failed to create user or start chat: \(responseError(error))")
}
private func canCreateProfile(_ displayName: String) -> Bool {
diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
index c1975765d2..7452d74e91 100644
--- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
+++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
@@ -9,24 +9,24 @@
import SwiftUI
struct HowItWorks: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
var onboarding: Bool
+ @Binding var createProfileNavLinkActive: Bool
var body: some View {
VStack(alignment: .leading) {
Text("How SimpleX works")
.font(.largeTitle)
+ .bold()
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
- Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*")
- Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.")
- Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.")
- Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.")
- if onboarding {
- Text("Read more in our GitHub repository.")
- } else {
+ Text("To protect your privacy, SimpleX uses separate IDs for each of your contacts.")
+ Text("Only client devices store user profiles, contacts, groups, and messages.")
+ Text("All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.")
+ if !onboarding {
Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
}
}
@@ -37,19 +37,34 @@ struct HowItWorks: View {
Spacer()
if onboarding {
- OnboardingActionButton()
- .padding(.bottom, 8)
+ VStack(spacing: 10) {
+ createFirstProfileButton()
+ onboardingButtonPlaceholder()
+ }
}
}
.lineLimit(10)
- .padding()
+ .padding(onboarding ? 25 : 16)
.frame(maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground())
}
+
+ private func createFirstProfileButton() -> some View {
+ Button {
+ dismiss()
+ createProfileNavLinkActive = true
+ } label: {
+ Text("Create your profile")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ }
}
struct HowItWorks_Previews: PreviewProvider {
static var previews: some View {
- HowItWorks(onboarding: true)
+ HowItWorks(
+ onboarding: true,
+ createProfileNavLinkActive: Binding.constant(false)
+ )
}
}
diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
index 438491b5f1..b2b1b8fa68 100644
--- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
+++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
@@ -12,20 +12,39 @@ struct OnboardingView: View {
var onboarding: OnboardingStage
var body: some View {
- switch onboarding {
- case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
- case .step2_CreateProfile: CreateFirstProfile()
- case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
- case .step4_SetNotificationsMode: SetNotificationsMode()
- case .onboardingComplete: EmptyView()
+ NavigationView {
+ switch onboarding {
+ case .step1_SimpleXInfo:
+ SimpleXInfo(onboarding: true)
+ .modifier(ThemedBackground())
+ case .step2_CreateProfile: // deprecated
+ CreateFirstProfile()
+ .modifier(ThemedBackground())
+ case .step3_CreateSimpleXAddress: // deprecated
+ CreateSimpleXAddress()
+ case .step3_ChooseServerOperators:
+ ChooseServerOperators(onboarding: true)
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ case .step4_SetNotificationsMode:
+ SetNotificationsMode()
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ case .onboardingComplete: EmptyView()
+ }
}
}
}
+func onboardingButtonPlaceholder() -> some View {
+ Spacer().frame(height: 40)
+}
+
enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo
- case step2_CreateProfile
- case step3_CreateSimpleXAddress
+ case step2_CreateProfile // deprecated
+ case step3_CreateSimpleXAddress // deprecated
+ case step3_ChooseServerOperators
case step4_SetNotificationsMode
case onboardingComplete
diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
index 7681a42a77..97e1f49382 100644
--- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
+++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
@@ -13,42 +13,54 @@ struct SetNotificationsMode: View {
@EnvironmentObject var m: ChatModel
@State private var notificationMode = NotificationsMode.instant
@State private var showAlert: NotificationAlert?
+ @State private var showInfo: Bool = false
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- Text("Push notifications")
- .font(.largeTitle)
- .bold()
- .frame(maxWidth: .infinity)
+ GeometryReader { g in
+ ScrollView {
+ VStack(alignment: .center, spacing: 20) {
+ Text("Push notifications")
+ .font(.largeTitle)
+ .bold()
+ .padding(.top, 25)
+
+ infoText()
+
+ Spacer()
- Text("Send notifications:")
- ForEach(NotificationsMode.values) { mode in
- NtfModeSelector(mode: mode, selection: $notificationMode)
- }
-
- Spacer()
-
- Button {
- if let token = m.deviceToken {
- setNotificationsMode(token, notificationMode)
- } else {
- AlertManager.shared.showAlertMsg(title: "No device token!")
+ ForEach(NotificationsMode.values) { mode in
+ NtfModeSelector(mode: mode, selection: $notificationMode)
}
- onboardingStageDefault.set(.onboardingComplete)
- m.onboardingStage = .onboardingComplete
- } label: {
- if case .off = notificationMode {
- Text("Use chat")
- } else {
- Text("Enable notifications")
+
+ Spacer()
+
+ VStack(spacing: 10) {
+ Button {
+ if let token = m.deviceToken {
+ setNotificationsMode(token, notificationMode)
+ } else {
+ AlertManager.shared.showAlertMsg(title: "No device token!")
+ }
+ onboardingStageDefault.set(.onboardingComplete)
+ m.onboardingStage = .onboardingComplete
+ } label: {
+ if case .off = notificationMode {
+ Text("Use chat")
+ } else {
+ Text("Enable notifications")
+ }
+ }
+ .buttonStyle(OnboardingButtonStyle())
+ onboardingButtonPlaceholder()
}
}
- .font(.title)
- .frame(maxWidth: .infinity)
+ .padding(25)
+ .frame(minHeight: g.size.height)
}
- .padding()
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ }
+ .frame(maxHeight: .infinity)
+ .sheet(isPresented: $showInfo) {
+ NotificationsInfoView()
}
}
@@ -75,6 +87,15 @@ struct SetNotificationsMode: View {
}
}
}
+
+ private func infoText() -> some View {
+ Button {
+ showInfo = true
+ } label: {
+ Label("How it affects privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
}
struct NtfModeSelector: View {
@@ -85,15 +106,25 @@ struct NtfModeSelector: View {
var body: some View {
ZStack {
- VStack(alignment: .leading, spacing: 4) {
- Text(mode.label)
- .font(.headline)
+ HStack(spacing: 16) {
+ Image(systemName: mode.icon)
+ .resizable()
+ .scaledToFill()
+ .frame(width: mode.icon == "bolt" ? 14 : 18, height: 18)
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
- Text(ntfModeDescription(mode))
- .lineLimit(10)
- .font(.subheadline)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(mode.label)
+ .font(.headline)
+ .foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
+ Text(ntfModeShortDescription(mode))
+ .lineLimit(2)
+ .font(.callout)
+ .fixedSize(horizontal: false, vertical: true)
+ }
}
- .padding(12)
+ .padding(.vertical, 12)
+ .padding(.trailing, 12)
+ .padding(.leading, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(tapped ? Color(uiColor: .secondarySystemFill) : theme.colors.background)
@@ -109,6 +140,37 @@ struct NtfModeSelector: View {
}
}
+struct NotificationsInfoView: View {
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Notifications privacy")
+ .font(.largeTitle)
+ .bold()
+ .padding(.vertical)
+ ScrollView {
+ VStack(alignment: .leading) {
+ Group {
+ ForEach(NotificationsMode.values) { mode in
+ VStack(alignment: .leading, spacing: 4) {
+ (Text(Image(systemName: mode.icon)) + textSpace + Text(mode.label))
+ .font(.headline)
+ .foregroundColor(.secondary)
+ Text(ntfModeDescription(mode))
+ .lineLimit(10)
+ .font(.callout)
+ }
+ }
+ }
+ .padding(.bottom)
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .modifier(ThemedBackground())
+ }
+}
+
struct NotificationsModeView_Previews: PreviewProvider {
static var previews: some View {
SetNotificationsMode()
diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
index ee5a618e68..a8704e964b 100644
--- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
+++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
@@ -13,58 +13,58 @@ struct SimpleXInfo: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var showHowItWorks = false
+ @State private var createProfileNavLinkActive = false
var onboarding: Bool
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading) {
- Image(colorScheme == .light ? "logo" : "logo-light")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: g.size.width * 0.67)
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
+ VStack(alignment: .center, spacing: 10) {
+ Image(colorScheme == .light ? "logo" : "logo-light")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: g.size.width * 0.67)
+ .padding(.bottom, 8)
+ .padding(.leading, 4)
+ .frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
+
+ Button {
+ showHowItWorks = true
+ } label: {
+ Label("The future of messaging", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
+
+ Spacer()
VStack(alignment: .leading) {
- Text("The next generation of private messaging")
- .font(.title2)
- .padding(.bottom, 30)
- .padding(.horizontal, 40)
- .frame(maxWidth: .infinity)
- .multilineTextAlignment(.center)
- infoRow("privacy", "Privacy redefined",
- "The 1st platform without any user identifiers – private by design.", width: 48)
- infoRow("shield", "Immune to spam and abuse",
- "People can connect to you only via the links you share.", width: 46)
- infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
- "Open-source protocol and code – anybody can run the servers.", width: 44)
+ onboardingInfoRow("privacy", "Privacy redefined",
+ "No user identifiers.", width: 48)
+ onboardingInfoRow("shield", "Immune to spam",
+ "You decide who can connect.", width: 46)
+ onboardingInfoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
+ "Anybody can host servers.", width: 46)
}
+ .padding(.leading, 16)
Spacer()
+
if onboarding {
- OnboardingActionButton()
- Spacer()
+ VStack(spacing: 10) {
+ createFirstProfileButton()
- Button {
- m.migrationState = .pasteOrScanLink
- } label: {
- Label("Migrate from another device", systemImage: "tray.and.arrow.down")
- .font(.subheadline)
+ Button {
+ m.migrationState = .pasteOrScanLink
+ } label: {
+ Label("Migrate from another device", systemImage: "tray.and.arrow.down")
+ .font(.system(size: 17, weight: .semibold))
+ .frame(minHeight: 40)
+ }
+ .frame(maxWidth: .infinity)
}
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity)
}
-
- Button {
- showHowItWorks = true
- } label: {
- Label("How it works", systemImage: "info.circle")
- .font(.subheadline)
- }
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity)
-
}
.frame(minHeight: g.size.height)
}
@@ -83,76 +83,59 @@ struct SimpleXInfo: View {
}
}
.sheet(isPresented: $showHowItWorks) {
- HowItWorks(onboarding: onboarding)
+ HowItWorks(
+ onboarding: onboarding,
+ createProfileNavLinkActive: $createProfileNavLinkActive
+ )
}
}
.frame(maxHeight: .infinity)
- .padding()
+ .padding(.horizontal, 25)
+ .padding(.top, 75)
+ .padding(.bottom, 25)
}
- private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
+ private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
HStack(alignment: .top) {
Image(image)
.resizable()
.scaledToFit()
.frame(width: width, height: 54)
.frame(width: 54)
- .padding(.top, 4)
- .padding(.leading, 4)
.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(text).frame(minHeight: 40, alignment: .top)
+ .font(.callout)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
}
+ .padding(.top, 4)
+ }
+ .padding(.bottom, 12)
+ }
+
+ private func createFirstProfileButton() -> some View {
+ ZStack {
+ Button {
+ createProfileNavLinkActive = true
+ } label: {
+ Text("Create your profile")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
+
+ NavigationLink(isActive: $createProfileNavLinkActive) {
+ CreateFirstProfile()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
- .padding(.bottom, 20)
- .padding(.trailing, 6)
}
}
-struct OnboardingActionButton: View {
- @EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
-
- var body: some View {
- if m.currentUser == nil {
- actionButton("Create your profile", onboarding: .step2_CreateProfile)
- } else {
- actionButton("Make a private connection", onboarding: .onboardingComplete)
- }
- }
-
- private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
- Button {
- withAnimation {
- onboardingStageDefault.set(onboarding)
- m.onboardingStage = onboarding
- }
- } label: {
- HStack {
- Text(label).font(.title2)
- Image(systemName: "greaterthan")
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.bottom)
- }
-
- private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
- Button {
- withAnimation {
- action()
- }
- } label: {
- HStack {
- Text(label).font(.title2)
- Image(systemName: "greaterthan")
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.bottom)
- }
-}
+let textSpace = Text(verbatim: " ")
struct SimpleXInfo_Previews: PreviewProvider {
static var previews: some View {
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index ed3adcfe7d..182c5652d7 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -7,190 +7,209 @@
//
import SwiftUI
+import SimpleXChat
private struct VersionDescription {
var version: String
var post: URL?
- var features: [FeatureDescription]
+ var features: [Feature]
}
-private struct FeatureDescription {
- var icon: String?
- var title: LocalizedStringKey
- var description: LocalizedStringKey?
+private enum Feature: Identifiable {
+ case feature(Description)
+ case view(FeatureView)
+
+ var id: LocalizedStringKey {
+ switch self {
+ case let .feature(d): d.title
+ case let .view(v): v.title
+ }
+ }
+}
+
+private struct Description {
+ let icon: String?
+ let title: LocalizedStringKey
+ let description: LocalizedStringKey?
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
}
+private struct FeatureView {
+ let icon: String?
+ let title: LocalizedStringKey
+ let view: () -> any View
+}
+
private let versionDescriptions: [VersionDescription] = [
VersionDescription(
version: "v4.2",
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "checkmark.shield",
title: "Security assessment",
description: "SimpleX Chat security was audited by Trail of Bits."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.2",
title: "Group links",
description: "Admins can create the links to join groups."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "checkmark",
title: "Auto-accept contact requests",
description: "With optional welcome message."
- ),
+ )),
]
),
VersionDescription(
version: "v4.3",
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "mic",
title: "Voice messages",
description: "Max 30 seconds, received instantly."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "trash.slash",
title: "Irreversible message deletion",
description: "Your contacts can allow full message deletion."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "externaldrive.connected.to.line.below",
title: "Improved server configuration",
description: "Add servers by scanning QR codes."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "eye.slash",
title: "Improved privacy and security",
description: "Hide app screen in the recent apps."
- ),
+ )),
]
),
VersionDescription(
version: "v4.4",
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "stopwatch",
title: "Disappearing messages",
description: "Sent messages will be deleted after set time."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "ellipsis.circle",
title: "Live messages",
description: "Recipients see updates as you type them."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "checkmark.shield",
title: "Verify connection security",
description: "Compare security codes with your contacts."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "camera",
title: "GIFs and stickers",
description: "Send them from gallery or custom keyboards."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "French interface",
description: "Thanks to the users – contribute via Weblate!"
- )
+ )),
]
),
VersionDescription(
version: "v4.5",
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "person.crop.rectangle.stack",
title: "Multiple chat profiles",
description: "Different names, avatars and transport isolation."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "rectangle.and.pencil.and.ellipsis",
title: "Message draft",
description: "Preserve the last message draft, with attachments."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "network.badge.shield.half.filled",
title: "Transport isolation",
description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock.doc",
title: "Private filenames",
description: "To protect timezone, image/voice files use UTC."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.25",
title: "Reduced battery usage",
description: "More improvements are coming soon!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Italian interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- )
+ )),
]
),
VersionDescription(
version: "v4.6",
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "lock",
title: "Hidden chat profiles",
description: "Protect your chat profiles with a password!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "phone.arrow.up.right",
title: "Audio and video calls",
description: "Fully re-implemented - work in background!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "flag",
title: "Group moderation",
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "plus.message",
title: "Group welcome message",
description: "Set the message shown to new members!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Further reduced battery usage",
description: "More improvements are coming soon!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Chinese and Spanish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.0",
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "arrow.up.doc",
title: "Videos and files up to 1gb",
description: "Fast and no wait until the sender is online!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "App passcode",
description: "Set it instead of system authentication."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Polish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
// Also
@@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
version: "v5.1",
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "face.smiling",
title: "Message reactions",
description: "Finally, we have them! 🚀"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "arrow.up.message",
title: "Better messages",
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "Self-destruct passcode",
description: "All data is erased when it is entered."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Japanese interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.2",
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "checkmark",
title: "Message delivery receipts!",
description: "The second tick we missed! ✅"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "star",
title: "Find chats faster",
description: "Filter unread and favorite chats."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "exclamationmark.arrow.triangle.2.circlepath",
title: "Keep your connections",
description: "Fix encryption after restoring backups."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "stopwatch",
title: "Make one message disappear",
description: "Even when disabled in the conversation."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "gift",
title: "A few more things",
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.3",
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "desktopcomputer",
title: "New desktop app!",
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "Encrypt stored files & media",
description: "App encrypts new local files (except videos)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
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(
+ )),
+ .feature(Description(
icon: "theatermasks",
title: "Simplified incognito mode",
description: "Toggle incognito when connecting."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
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(
+ .feature(Description(
icon: "desktopcomputer",
title: "Link mobile and desktop apps! 🔗",
description: "Via secure quantum resistant protocol."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.2",
title: "Better groups",
description: "Faster joining and more reliable messages."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "theatermasks",
title: "Incognito groups",
description: "Create a group using a random profile."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "hand.raised",
title: "Block group members",
description: "To hide unwanted messages."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "gift",
title: "A few more things",
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.5",
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "folder",
title: "Private notes",
description: "With encrypted files and media."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "link",
title: "Paste link to connect!",
description: "Search bar accepts invitation links."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "bubble.left.and.bubble.right",
title: "Join group conversations",
description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Turkish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.6",
post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Enable in direct chats (BETA)!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "tray.and.arrow.up",
title: "App data migration",
description: "Migrate to another device via QR code."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "phone",
title: "Picture-in-picture calls",
description: "Use the app while in the call."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "hand.raised",
title: "Safer groups",
description: "Admins can block a member for all."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Hungarian interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.7",
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Will be enabled in direct chats!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "arrowshape.turn.up.forward",
title: "Forward and save messages",
description: "Message source remains private."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "music.note",
title: "In-call sounds",
description: "When connecting audio and video calls."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.crop.square",
title: "Shape profile images",
description: "Square, circle, or anything in between."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "antenna.radiowaves.left.and.right",
title: "Network management",
description: "More reliable network connection."
- )
+ )),
]
),
VersionDescription(
version: "v5.8",
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "network.badge.shield.half.filled",
title: "Safely receive files",
description: "Confirm files from unknown servers."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
- )
+ )),
]
),
VersionDescription(
version: "v6.0",
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: nil,
title: "New chat experience 🎉",
description: nil,
@@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
("paintpalette", "Color chats with the new themes."),
]
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: nil,
title: "New media options",
description: nil,
@@ -454,19 +473,73 @@ private let versionDescriptions: [VersionDescription] = [
("play.circle", "Play from the chat list."),
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
]
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "It protects your IP address and connections."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "network",
title: "Better networking",
description: "Connection and servers status."
- )
+ )),
]
),
+ VersionDescription(
+ version: "v6.1",
+ post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
+ features: [
+ .feature(Description(
+ icon: "checkmark.shield",
+ title: "Better security ✅",
+ description: "SimpleX protocols reviewed by Trail of Bits."
+ )),
+ .feature(Description(
+ icon: "video",
+ title: "Better calls",
+ description: "Switch audio and video during the call."
+ )),
+ .feature(Description(
+ icon: "bolt",
+ title: "Better notifications",
+ description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
+ )),
+ .feature(Description(
+ icon: nil,
+ title: "Better user experience",
+ description: nil,
+ subfeatures: [
+ ("link", "Switch chat profile for 1-time invitations."),
+ ("message", "Customizable message shape."),
+ ("calendar", "Better message dates."),
+ ("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
+ ("flag", "Delete or moderate up to 200 messages.")
+ ]
+ )),
+ ]
+ ),
+ VersionDescription(
+ version: "v6.2",
+ post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"),
+ features: [
+ .view(FeatureView(
+ icon: nil,
+ title: "Network decentralization",
+ view: { NewOperatorsView() }
+ )),
+ .feature(Description(
+ icon: "briefcase",
+ title: "Business chats",
+ description: "Privacy for your customers."
+ )),
+ .feature(Description(
+ icon: "bolt",
+ title: "More reliable notifications",
+ description: "Delivered even when Apple drops them."
+ )),
+ ]
+ )
]
private let lastVersion = versionDescriptions.last!.version
@@ -481,14 +554,68 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion
}
+fileprivate struct NewOperatorsView: View {
+ @State private var showOperatorsSheet = false
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ Text("The second preset operator in the app!")
+ .multilineTextAlignment(.leading)
+ .lineLimit(10)
+ HStack {
+ Button("Enable Flux") {
+ showOperatorsSheet = true
+ }
+ Text("for better metadata privacy.")
+ }
+ }
+ .sheet(isPresented: $showOperatorsSheet) {
+ NavigationView {
+ ChooseServerOperators(onboarding: false)
+ .modifier(ThemedBackground())
+ }
+ }
+ }
+}
+
+private enum WhatsNewViewSheet: Identifiable {
+ case showConditions
+
+ var id: String {
+ switch self {
+ case .showConditions: return "showConditions"
+ }
+ }
+}
+
struct WhatsNewView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@State var currentVersion = versionDescriptions.count - 1
@State var currentVersionNav = versionDescriptions.count - 1
var viaSettings = false
+ var updatedConditions: Bool
+ @State private var sheetItem: WhatsNewViewSheet? = nil
var body: some View {
+ whatsNewView()
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showConditions:
+ UsageConditionsView(
+ currUserServers: Binding.constant([]),
+ userServers: Binding.constant([])
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+
+ private func whatsNewView() -> some View {
VStack {
TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
@@ -499,9 +626,11 @@ struct WhatsNewView: View {
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical)
- ForEach(v.features, id: \.title) { f in
- featureDescription(f)
- .padding(.bottom, 8)
+ ForEach(v.features) { f in
+ switch f {
+ case let .feature(d): featureDescription(d).padding(.bottom, 8)
+ case let .view(v): AnyView(v.view()).padding(.bottom, 8)
+ }
}
if let post = v.post {
Link(destination: post) {
@@ -511,13 +640,20 @@ struct WhatsNewView: View {
}
}
}
+ if updatedConditions {
+ Button("View updated conditions") {
+ sheetItem = .showConditions
+ }
+ }
if !viaSettings {
Spacer()
+
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
+
Spacer()
}
}
@@ -535,20 +671,24 @@ struct WhatsNewView: View {
currentVersionNav = currentVersion
}
}
-
- private func featureDescription(_ f: FeatureDescription) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- if let icon = f.icon {
- HStack(alignment: .center, spacing: 4) {
- Image(systemName: icon)
- .symbolRenderingMode(.monochrome)
- .foregroundColor(theme.colors.secondary)
- .frame(minWidth: 30, alignment: .center)
- Text(f.title).font(.title3).bold()
- }
- } else {
- Text(f.title).font(.title3).bold()
+
+ @ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View {
+ if let icon {
+ HStack(alignment: .center, spacing: 4) {
+ Image(systemName: icon)
+ .symbolRenderingMode(.monochrome)
+ .foregroundColor(theme.colors.secondary)
+ .frame(minWidth: 30, alignment: .center)
+ Text(title).font(.title3).bold()
}
+ } else {
+ Text(title).font(.title3).bold()
+ }
+ }
+
+ private func featureDescription(_ f: Description) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ featureHeader(f.icon, f.title)
if let d = f.description {
Text(d)
.multilineTextAlignment(.leading)
@@ -603,6 +743,6 @@ struct WhatsNewView: View {
struct NewFeaturesView_Previews: PreviewProvider {
static var previews: some View {
- WhatsNewView()
+ WhatsNewView(updatedConditions: false)
}
}
diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
index be063334d3..67020e09e7 100644
--- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
+++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
@@ -14,7 +14,6 @@ struct ConnectDesktopView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@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
@@ -57,23 +56,6 @@ struct ConnectDesktopView: View {
}
var body: some View {
- if viaSettings {
- viewBody
- .modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
- 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) {
@@ -286,7 +268,7 @@ struct ConnectDesktopView: View {
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()
+ t = t + textSpace + Text("(new)").italic()
}
return t
}
@@ -295,7 +277,7 @@ struct ConnectDesktopView: View {
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()
+ t = t + textSpace + Text("(this device v\(session.appVersion))").italic()
}
return t
}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index d209ced128..23e1f783f7 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -105,7 +105,12 @@ struct TerminalView: View {
}
}
.navigationViewStyle(.stack)
- .navigationTitle("Chat console")
+ .toolbar {
+ // Redaction broken for `.navigationTitle` - using a toolbar item instead.
+ ToolbarItem(placement: .principal) {
+ Text("Chat console").font(.headline)
+ }
+ }
.modifier(ThemedBackground())
}
@@ -160,7 +165,7 @@ struct TerminalView_Previews: PreviewProvider {
let chatModel = ChatModel()
chatModel.terminalItems = [
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
- .resp(.now, ChatResponse.response(type: "newChatItem", json: "{}"))
+ .resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
]
return NavigationView {
TerminalView()
diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
index 6247777bf2..44e0b20958 100644
--- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
@@ -19,9 +19,15 @@ extension AppSettings {
val.hostMode = .publicHost
val.requiredHostMode = true
}
- val.socksProxy = nil
- setNetCfg(val)
+ if val.socksProxy != nil {
+ val.socksProxy = networkProxy?.toProxyString()
+ setNetCfg(val, networkProxy: networkProxy)
+ } else {
+ val.socksProxy = nil
+ setNetCfg(val, networkProxy: nil)
+ }
}
+ if let val = networkProxy { networkProxyDefault.set(val) }
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) }
if let val = privacyAcceptImages {
@@ -52,17 +58,21 @@ extension AppSettings {
profileImageCornerRadiusGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
}
- if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
- if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
- if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
- if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
+ if let val = uiChatItemRoundness { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)}
+ if let val = uiChatItemTail { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_TAIL)}
+ if let val = uiColorScheme { currentThemeDefault.set(val) }
+ if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) }
+ if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
+ if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
+ if let val = chatBottomBar { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) }
}
public static var current: AppSettings {
let def = UserDefaults.standard
var c = AppSettings.defaults
c.networkConfig = getNetCfg()
+ c.networkProxy = networkProxyDefault.get()
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
@@ -84,11 +94,14 @@ extension AppSettings {
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
+ c.uiChatItemRoundness = def.double(forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)
+ c.uiChatItemTail = def.bool(forKey: DEFAULT_CHAT_ITEM_TAIL)
c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
c.uiThemes = themeOverridesDefault.get()
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
+ c.chatBottomBar = groupDefaults.bool(forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR)
return c
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
index 73a789f108..4c61d592ac 100644
--- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
@@ -33,6 +33,8 @@ struct AppearanceSettings: View {
}()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
+ @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
+ @AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -179,6 +181,14 @@ struct AppearanceSettings: View {
}
}
+ Section(header: Text("Message shape").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Text("Corner")
+ Slider(value: $chatItemRoundness, in: 0...1, step: 0.05)
+ }
+ Toggle("Tail", isOn: $chatItemTail)
+ }
+
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" {
@@ -357,21 +367,22 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
- ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
- .modifier(ChatItemClipped())
+ ChatItemView(chat: Chat.sampleData, chatItem: alice)
+ .modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
- ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
- .modifier(ChatItemClipped())
+ ChatItemView(chat: Chat.sampleData, chatItem: bob)
+ .modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
} else {
Rectangle().fill(.clear)
}
}
- .padding(10)
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
.frame(maxWidth: .infinity)
if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor {
@@ -572,11 +583,14 @@ struct CustomizeThemeView: View {
}
}
- ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil)
+ }
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { theme in
ThemeManager.saveAndApplyThemeOverrides(theme)
saveThemeToDatabase(nil)
- })
- }
+ }
+ )
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
.interactiveDismissDisabled(true)
}
@@ -584,10 +598,9 @@ struct CustomizeThemeView: View {
struct ImportExportThemeSection: View {
@EnvironmentObject var theme: AppTheme
+ @Binding var showFileImporter: Bool
var perChat: ThemeModeOverride?
var perUser: ThemeModeOverrides?
- var save: (ThemeOverrides) -> Void
- @State private var showFileImporter = false
var body: some View {
Section {
@@ -615,39 +628,47 @@ struct ImportExportThemeSection: View {
} label: {
Text("Import theme").foregroundColor(theme.colors.primary)
}
- .fileImporter(
- isPresented: $showFileImporter,
- allowedContentTypes: [.data/*.plainText*/],
- allowsMultipleSelection: false
- ) { result in
- if case let .success(files) = result, let fileURL = files.first {
- do {
- var fileSize: Int? = nil
- if fileURL.startAccessingSecurityScopedResource() {
- let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
- fileSize = resourceValues.fileSize
- }
- if let fileSize = fileSize,
- // Same as Android/desktop
- fileSize <= 5_500_000 {
- if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
- save(theme)
- logger.error("Saved theme from file")
- } else {
- logger.error("Error decoding theme file")
- }
- fileURL.stopAccessingSecurityScopedResource()
- } else {
- fileURL.stopAccessingSecurityScopedResource()
- let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
- AlertManager.shared.showAlertMsg(
- title: "Large file!",
- message: "Currently maximum supported file size is \(prettyMaxFileSize)."
- )
- }
- } catch {
- logger.error("Appearance fileImporter error \(error.localizedDescription)")
+ }
+ }
+}
+
+struct ThemeImporter: ViewModifier {
+ @Binding var isPresented: Bool
+ var save: (ThemeOverrides) -> Void
+
+ func body(content: Content) -> some View {
+ content.fileImporter(
+ isPresented: $isPresented,
+ allowedContentTypes: [.data/*.plainText*/],
+ allowsMultipleSelection: false
+ ) { result in
+ if case let .success(files) = result, let fileURL = files.first {
+ do {
+ var fileSize: Int? = nil
+ if fileURL.startAccessingSecurityScopedResource() {
+ let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
+ fileSize = resourceValues.fileSize
}
+ if let fileSize = fileSize,
+ // Same as Android/desktop
+ fileSize <= 5_500_000 {
+ if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
+ save(theme)
+ logger.error("Saved theme from file")
+ } else {
+ logger.error("Error decoding theme file")
+ }
+ fileURL.stopAccessingSecurityScopedResource()
+ } else {
+ fileURL.stopAccessingSecurityScopedResource()
+ let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Currently maximum supported file size is \(prettyMaxFileSize)."
+ )
+ }
+ } catch {
+ logger.error("Appearance fileImporter error \(error.localizedDescription)")
}
}
}
@@ -797,7 +818,7 @@ struct ThemeDestinationPicker: View {
@Binding var customizeThemeIsOpen: Bool
var body: some View {
- let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
+ let values = [(nil, NSLocalizedString("All profiles", comment: "profile dropdown"))] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
Picker("Apply to", selection: $themeUserDest) {
diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
index 4ef05bd998..513a6c2708 100644
--- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
+++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
@@ -45,7 +45,7 @@ struct DeveloperView: View {
} header: {
Text("")
} footer: {
- ((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
+ ((developerTools ? Text("Show:") : Text("Hide:")) + textSpace + Text("Database IDs and Transport isolation option."))
.foregroundColor(theme.colors.secondary)
}
diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
index a0250afddf..d9862aaac8 100644
--- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
+++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
@@ -26,6 +26,7 @@ struct IncognitoHelp: View {
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
}
.listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.modifier(ThemedBackground())
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift
deleted file mode 100644
index 155a3956be..0000000000
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-//
-// NetworkServersView.swift
-// SimpleX (iOS)
-//
-// Created by Evgeny on 02/08/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-private enum NetworkAlert: Identifiable {
- case error(err: String)
-
- var id: String {
- switch self {
- case let .error(err): return "error \(err)"
- }
- }
-}
-
-struct NetworkAndServers: View {
- @EnvironmentObject var m: ChatModel
- @EnvironmentObject var theme: AppTheme
-
- var body: some View {
- VStack {
- List {
- Section {
- NavigationLink {
- ProtocolServersView(serverProtocol: .smp)
- .navigationTitle("Your SMP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Message servers")
- }
-
- NavigationLink {
- ProtocolServersView(serverProtocol: .xftp)
- .navigationTitle("Your XFTP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Media & file servers")
- }
-
- NavigationLink {
- AdvancedNetworkSettings()
- .navigationTitle("Advanced settings")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Advanced network settings")
- }
- } header: {
- Text("Messages & files")
- .foregroundColor(theme.colors.secondary)
- }
-
- Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
- NavigationLink {
- RTCServers()
- .navigationTitle("Your ICE servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("WebRTC ICE servers")
- }
- }
-
- Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
- HStack {
- Text(m.networkInfo.networkType.text)
- Spacer()
- Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
- }
- }
- }
- }
- }
-}
-
-struct NetworkServersView_Previews: PreviewProvider {
- static var previews: some View {
- NetworkAndServers()
- }
-}
diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
similarity index 69%
rename from apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
index 99c0a588eb..754ca3cf6b 100644
--- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
@@ -36,6 +36,10 @@ struct AdvancedNetworkSettings: View {
@State private var showSettingsAlert: NetworkSettingsAlert?
@State private var onionHosts: OnionHosts = .no
@State private var showSaveDialog = false
+ @State private var netProxy = networkProxyDefault.get()
+ @State private var currentNetProxy = networkProxyDefault.get()
+ @State private var useNetProxy = false
+ @State private var netProxyAuth = false
var body: some View {
VStack {
@@ -102,6 +106,76 @@ struct AdvancedNetworkSettings: View {
.foregroundColor(theme.colors.secondary)
}
+ Section {
+ Toggle("Use SOCKS proxy", isOn: $useNetProxy)
+ Group {
+ TextField("IP address", text: $netProxy.host)
+ TextField(
+ "Port",
+ text: Binding(
+ get: { netProxy.port > 0 ? "\(netProxy.port)" : "" },
+ set: { s in
+ netProxy.port = if let port = Int(s), port > 0 {
+ port
+ } else {
+ 0
+ }
+ }
+ )
+ )
+ Toggle("Proxy requires password", isOn: $netProxyAuth)
+ if netProxyAuth {
+ TextField("Username", text: $netProxy.username)
+ PassphraseField(
+ key: $netProxy.password,
+ placeholder: "Password",
+ valid: NetworkProxy.validCredential(netProxy.password)
+ )
+ }
+ }
+ .if(!useNetProxy) { $0.foregroundColor(theme.colors.secondary) }
+ .disabled(!useNetProxy)
+ } header: {
+ HStack {
+ Text("SOCKS proxy").foregroundColor(theme.colors.secondary)
+ if useNetProxy && !netProxy.valid {
+ Spacer()
+ Image(systemName: "exclamationmark.circle.fill").foregroundColor(.red)
+ }
+ }
+ } footer: {
+ if netProxyAuth {
+ Text("Your credentials may be sent unencrypted.")
+ .foregroundColor(theme.colors.secondary)
+ } else {
+ Text("Do not use credentials with proxy.")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ .onChange(of: useNetProxy) { useNetProxy in
+ netCfg.socksProxy = useNetProxy && currentNetProxy.valid
+ ? currentNetProxy.toProxyString()
+ : nil
+ netProxy = currentNetProxy
+ netProxyAuth = netProxy.username != "" || netProxy.password != ""
+ }
+ .onChange(of: netProxyAuth) { netProxyAuth in
+ if netProxyAuth {
+ netProxy.auth = currentNetProxy.auth
+ netProxy.username = currentNetProxy.username
+ netProxy.password = currentNetProxy.password
+ } else {
+ netProxy.auth = .username
+ netProxy.username = ""
+ netProxy.password = ""
+ }
+ }
+ .onChange(of: netProxy) { netProxy in
+ netCfg.socksProxy = useNetProxy && netProxy.valid
+ ? netProxy.toProxyString()
+ : nil
+ }
+
Section {
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
@@ -122,11 +196,14 @@ struct AdvancedNetworkSettings: View {
if developerTools {
Section {
Picker("Transport isolation", selection: $netCfg.sessionMode) {
- ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
+ let modes = TransportSessionMode.values.contains(netCfg.sessionMode)
+ ? TransportSessionMode.values
+ : TransportSessionMode.values + [netCfg.sessionMode]
+ ForEach(modes, id: \.self) { Text($0.text) }
}
.frame(height: 36)
} footer: {
- Text(sessionModeInfo(netCfg.sessionMode))
+ sessionModeInfo(netCfg.sessionMode)
.foregroundColor(theme.colors.secondary)
}
}
@@ -156,19 +233,19 @@ struct AdvancedNetworkSettings: View {
Section {
Button("Reset to defaults") {
- updateNetCfgView(NetCfg.defaults)
+ updateNetCfgView(NetCfg.defaults, NetworkProxy.def)
}
.disabled(netCfg == NetCfg.defaults)
Button("Set timeouts for proxy/VPN") {
- updateNetCfgView(netCfg.withProxyTimeouts)
+ updateNetCfgView(netCfg.withProxyTimeouts, netProxy)
}
.disabled(netCfg.hasProxyTimeouts)
Button("Save and reconnect") {
showSettingsAlert = .update
}
- .disabled(netCfg == currentNetCfg)
+ .disabled(netCfg == currentNetCfg || (useNetProxy && !netProxy.valid))
}
}
}
@@ -182,7 +259,8 @@ struct AdvancedNetworkSettings: View {
if cfgLoaded { return }
cfgLoaded = true
currentNetCfg = getNetCfg()
- updateNetCfgView(currentNetCfg)
+ currentNetProxy = networkProxyDefault.get()
+ updateNetCfgView(currentNetCfg, currentNetProxy)
}
.alert(item: $showSettingsAlert) { a in
switch a {
@@ -206,7 +284,7 @@ struct AdvancedNetworkSettings: View {
if netCfg == currentNetCfg {
dismiss()
cfgLoaded = false
- } else {
+ } else if !useNetProxy || netProxy.valid {
showSaveDialog = true
}
})
@@ -221,18 +299,26 @@ struct AdvancedNetworkSettings: View {
}
}
- private func updateNetCfgView(_ cfg: NetCfg) {
+ private func updateNetCfgView(_ cfg: NetCfg, _ proxy: NetworkProxy) {
netCfg = cfg
+ netProxy = proxy
onionHosts = OnionHosts(netCfg: netCfg)
enableKeepAlive = netCfg.enableKeepAlive
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
+ useNetProxy = netCfg.socksProxy != nil
+ netProxyAuth = switch netProxy.auth {
+ case .username: netProxy.username != "" || netProxy.password != ""
+ case .isolate: false
+ }
}
private func saveNetCfg() -> Bool {
do {
try setNetworkConfig(netCfg)
currentNetCfg = netCfg
- setNetCfg(netCfg)
+ setNetCfg(netCfg, networkProxy: useNetProxy ? netProxy : nil)
+ currentNetProxy = netProxy
+ networkProxyDefault.set(netProxy)
return true
} catch let error {
let err = responseError(error)
@@ -270,10 +356,13 @@ struct AdvancedNetworkSettings: View {
}
}
- private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
- switch mode {
- case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
- case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
+ private func sessionModeInfo(_ mode: TransportSessionMode) -> Text {
+ let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
+ return switch mode {
+ case .user: userMode
+ case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.")
+ case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.")
+ case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
new file mode 100644
index 0000000000..1e38b7d5ec
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
@@ -0,0 +1,83 @@
+//
+// ConditionsWebView.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 26.11.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import WebKit
+
+struct ConditionsWebView: UIViewRepresentable {
+ @State var html: String
+ @EnvironmentObject var theme: AppTheme
+ @State var pageLoaded = false
+
+ func makeUIView(context: Context) -> WKWebView {
+ let view = WKWebView()
+ view.backgroundColor = .clear
+ view.isOpaque = false
+ view.navigationDelegate = context.coordinator
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ // just to make sure that even if updateUIView will not be called for any reason, the page
+ // will be rendered anyway
+ if !pageLoaded {
+ loadPage(view)
+ }
+ }
+ return view
+ }
+
+ func updateUIView(_ view: WKWebView, context: Context) {
+ loadPage(view)
+ }
+
+ private func loadPage(_ webView: WKWebView) {
+ let styles = """
+
+ """
+ let head = "\(styles)"
+ webView.loadHTMLString(head + html, baseURL: nil)
+ DispatchQueue.main.async {
+ pageLoaded = true
+ }
+ }
+
+ func makeCoordinator() -> Cordinator {
+ Cordinator()
+ }
+
+ class Cordinator: NSObject, WKNavigationDelegate {
+ func webView(_ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+
+ guard let url = navigationAction.request.url else { return decisionHandler(.allow) }
+
+ switch navigationAction.navigationType {
+ case .linkActivated:
+ decisionHandler(.cancel)
+ if url.absoluteString.starts(with: "https://simplex.chat/contact#") {
+ ChatModel.shared.appOpenUrl = url
+ } else {
+ UIApplication.shared.open(url)
+ }
+ default:
+ decisionHandler(.allow)
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
new file mode 100644
index 0000000000..16aa98bc5f
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
@@ -0,0 +1,435 @@
+//
+// NetworkServersView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 02/08/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private enum NetworkAlert: Identifiable {
+ case error(err: String)
+
+ var id: String {
+ switch self {
+ case let .error(err): return "error \(err)"
+ }
+ }
+}
+
+private enum NetworkAndServersSheet: Identifiable {
+ case showConditions
+
+ var id: String {
+ switch self {
+ case .showConditions: return "showConditions"
+ }
+ }
+}
+
+struct NetworkAndServers: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var m: ChatModel
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var ss: SaveableSettings
+ @State private var sheetItem: NetworkAndServersSheet? = nil
+ @State private var justOpened = true
+ @State private var showSaveDialog = false
+
+ var body: some View {
+ VStack {
+ List {
+ let conditionsAction = m.conditions.conditionsAction
+ let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false })
+ Section {
+ ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
+ if let serverOperator = userOperatorServers.operator {
+ serverOperatorView(idx, serverOperator)
+ } else {
+ EmptyView()
+ }
+ }
+
+ if let conditionsAction = conditionsAction, anyOperatorEnabled {
+ conditionsButton(conditionsAction)
+ }
+ } header: {
+ Text("Preset servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ switch conditionsAction {
+ case let .review(_, deadline, _):
+ if let deadline = deadline, anyOperatorEnabled {
+ Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ default:
+ EmptyView()
+ }
+ }
+
+ Section {
+ if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) {
+ NavigationLink {
+ YourServersView(
+ userServers: $ss.servers.userServers,
+ serverErrors: $ss.servers.serverErrors,
+ operatorIndex: idx
+ )
+ .navigationTitle("Your servers")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ HStack {
+ Text("Your servers")
+
+ if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] {
+ Spacer()
+ unsavedChangesIndicator()
+ }
+ }
+ }
+ }
+
+ NavigationLink {
+ AdvancedNetworkSettings()
+ .navigationTitle("Advanced settings")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("Advanced network settings")
+ }
+ } header: {
+ Text("Messages & files")
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Section {
+ Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
+ .disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
+ } footer: {
+ if let errStr = globalServersError(ss.servers.serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else if !ss.servers.serverErrors.isEmpty {
+ ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
+ }
+ }
+
+ Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
+ NavigationLink {
+ RTCServers()
+ .navigationTitle("Your ICE servers")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("WebRTC ICE servers")
+ }
+ }
+
+ Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Text(m.networkInfo.networkType.text)
+ Spacer()
+ Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
+ }
+ }
+ }
+ }
+ .task {
+ // this condition is needed to prevent re-setting the servers when exiting single server view
+ if justOpened {
+ do {
+ ss.servers.currUserServers = try await getUserServers()
+ ss.servers.userServers = ss.servers.currUserServers
+ ss.servers.serverErrors = []
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error loading servers", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ justOpened = false
+ }
+ }
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) {
+ showSaveDialog = true
+ } else {
+ dismiss()
+ }
+ })
+ .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
+ Button("Save") {
+ saveServers($ss.servers.currUserServers, $ss.servers.userServers)
+ dismiss()
+ }
+ Button("Exit without saving") { dismiss() }
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showConditions:
+ UsageConditionsView(
+ currUserServers: $ss.servers.currUserServers,
+ userServers: $ss.servers.userServers
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+
+ private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View {
+ NavigationLink() {
+ OperatorView(
+ currUserServers: $ss.servers.currUserServers,
+ userServers: $ss.servers.userServers,
+ serverErrors: $ss.servers.serverErrors,
+ operatorIndex: operatorIndex,
+ useOperator: serverOperator.enabled
+ )
+ .navigationBarTitle("\(serverOperator.tradeName) servers")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Image(serverOperator.logo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .grayscale(serverOperator.enabled ? 0.0 : 1.0)
+ .frame(width: 24, height: 24)
+ Text(serverOperator.tradeName)
+ .foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary)
+
+ if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] {
+ Spacer()
+ unsavedChangesIndicator()
+ }
+ }
+ }
+ }
+
+ private func unsavedChangesIndicator() -> some View {
+ Image(systemName: "pencil")
+ .foregroundColor(theme.colors.secondary)
+ .symbolRenderingMode(.monochrome)
+ .frame(maxWidth: 24, maxHeight: 24, alignment: .center)
+ }
+
+ private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View {
+ Button {
+ sheetItem = .showConditions
+ } label: {
+ switch conditionsAction {
+ case .review:
+ Text("Review conditions")
+ case .accepted:
+ Text("Accepted conditions")
+ }
+ }
+ }
+}
+
+struct UsageConditionsView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @Binding var currUserServers: [UserOperatorServers]
+ @Binding var userServers: [UserOperatorServers]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ HStack {
+ Text("Conditions of use").font(.largeTitle).bold()
+ Spacer()
+ conditionsLinkButton()
+ }
+ .padding(.top)
+ .padding(.top)
+
+ switch ChatModel.shared.conditions.conditionsAction {
+
+ case .none:
+ ConditionsTextView()
+ .padding(.bottom)
+ .padding(.bottom)
+
+ case let .review(operators, deadline, _):
+ Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ ConditionsTextView()
+ VStack(spacing: 8) {
+ acceptConditionsButton(operators.map { $0.operatorId })
+ if let deadline = deadline {
+ Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 32)
+ }
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+
+
+ case let .accepted(operators):
+ Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ ConditionsTextView()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ }
+ .padding(.horizontal, 25)
+ .frame(maxHeight: .infinity)
+ }
+
+ private func acceptConditionsButton(_ operatorIds: [Int64]) -> some View {
+ Button {
+ acceptForOperators(operatorIds)
+ } label: {
+ Text("Accept conditions")
+ }
+ .buttonStyle(OnboardingButtonStyle())
+ }
+
+ func acceptForOperators(_ operatorIds: [Int64]) {
+ Task {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
+ await MainActor.run {
+ ChatModel.shared.conditions = r
+ updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
+ updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
+ dismiss()
+ }
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting conditions", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ }
+}
+
+func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
+ let userServersToValidate = userServers.wrappedValue
+ Task {
+ do {
+ let errs = try await validateServers(userServers: userServersToValidate)
+ await MainActor.run {
+ serverErrors.wrappedValue = errs
+ }
+ } catch let error {
+ logger.error("validateServers error: \(responseError(error))")
+ }
+ }
+}
+
+func serversCanBeSaved(
+ _ currUserServers: [UserOperatorServers],
+ _ userServers: [UserOperatorServers],
+ _ serverErrors: [UserServersError]
+) -> Bool {
+ return userServers != currUserServers && serverErrors.isEmpty
+}
+
+struct ServersErrorView: View {
+ @EnvironmentObject var theme: AppTheme
+ var errStr: String
+
+ var body: some View {
+ HStack {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ Text(errStr)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+func globalServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalSMPError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalXFTPError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set {
+ let duplicateHostsList = serverErrors.compactMap { err in
+ if case let .duplicateServer(_, _, duplicateHost) = err {
+ return duplicateHost
+ } else {
+ return nil
+ }
+ }
+ return Set(duplicateHostsList)
+}
+
+func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) {
+ let userServersToSave = userServers.wrappedValue
+ Task {
+ do {
+ try await setUserServers(userServers: userServersToSave)
+ // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
+ do {
+ let updatedServers = try await getUserServers()
+ let updatedOperators = try await getServerOperators()
+ await MainActor.run {
+ ChatModel.shared.conditions = updatedOperators
+ currUserServers.wrappedValue = updatedServers
+ userServers.wrappedValue = updatedServers
+ }
+ } catch let error {
+ logger.error("saveServers getUserServers error: \(responseError(error))")
+ await MainActor.run {
+ currUserServers.wrappedValue = userServersToSave
+ }
+ }
+ } catch let error {
+ logger.error("saveServers setUserServers error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error saving servers", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, _ updatedOperators: [ServerOperator]) {
+ for i in 0.. some View {
+ VStack {
+ let serverAddress = parseServerAddress(serverToEdit.server)
+ let valid = serverAddress?.valid == true
+ List {
+ Section {
+ TextEditor(text: $serverToEdit.server)
+ .multilineTextAlignment(.leading)
+ .autocorrectionDisabled(true)
+ .autocapitalization(.none)
+ .allowsTightening(true)
+ .lineLimit(10)
+ .frame(height: 144)
+ .padding(-6)
+ } header: {
+ HStack {
+ Text("Your server address")
+ .foregroundColor(theme.colors.secondary)
+ if !valid {
+ Spacer()
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+ }
+ }
+ useServerSection(valid)
+ if valid {
+ Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
+ MutableQRCode(uri: $serverToEdit.server)
+ .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
+ }
+ }
+ }
+ }
+ }
+
+ private func useServerSection(_ valid: Bool) -> some View {
+ Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Button("Test server") {
+ testing = true
+ serverToEdit.tested = nil
+ Task {
+ if let f = await testServerConnection(server: $serverToEdit) {
+ showTestFailure = true
+ testFailure = f
+ }
+ await MainActor.run { testing = false }
+ }
+ }
+ .disabled(!valid || testing)
+ Spacer()
+ showTestStatus(server: serverToEdit)
+ }
+ Toggle("Use for new connections", isOn: $serverToEdit.enabled)
+ }
+ }
+}
+
+func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? {
+ if let serverAddress = parseServerAddress(server.server) {
+ let serverProtocol = serverAddress.serverProtocol
+ let hostnames = serverAddress.hostnames
+ let matchingOperator = userServers.compactMap { $0.operator }.first { op in
+ op.serverDomains.contains { domain in
+ hostnames.contains { hostname in
+ hostname.hasSuffix(domain)
+ }
+ }
+ }
+ return (serverProtocol, matchingOperator)
+ } else {
+ return nil
+ }
+}
+
+func addServer(
+ _ server: UserServer,
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ serverErrors: Binding<[UserServersError]>,
+ _ dismiss: DismissAction
+) {
+ if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
+ if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) {
+ switch serverProtocol {
+ case .smp: userServers[i].wrappedValue.smpServers.append(server)
+ case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
+ }
+ validateServers_(userServers, serverErrors)
+ dismiss()
+ if let op = matchingOperator {
+ showAlert(
+ NSLocalizedString("Operator server", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName)
+ )
+ }
+ } else { // Shouldn't happen
+ dismiss()
+ showAlert(NSLocalizedString("Error adding server", comment: "alert title"))
+ }
+ } else {
+ dismiss()
+ if server.server.trimmingCharacters(in: .whitespaces) != "" {
+ showAlert(
+ NSLocalizedString("Invalid server address!", comment: "alert title"),
+ message: NSLocalizedString("Check server address and try again.", comment: "alert title")
+ )
+ }
+ }
+}
+
+#Preview {
+ NewServerView(
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([])
+ )
+}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
new file mode 100644
index 0000000000..cea9dd0635
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
@@ -0,0 +1,586 @@
+//
+// OperatorView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.10.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+import Ink
+
+struct OperatorView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.editMode) private var editMode
+ @Binding var currUserServers: [UserOperatorServers]
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var operatorIndex: Int
+ @State var useOperator: Bool
+ @State private var useOperatorToggleReset: Bool = false
+ @State private var showConditionsSheet: Bool = false
+ @State private var selectedServer: String? = nil
+ @State private var testing = false
+
+ var body: some View {
+ operatorView()
+ .opacity(testing ? 0.4 : 1)
+ .overlay {
+ if testing {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .allowsHitTesting(!testing)
+ }
+
+ @ViewBuilder private func operatorView() -> some View {
+ let duplicateHosts = findDuplicateHosts(serverErrors)
+ VStack {
+ List {
+ Section {
+ infoViewLink()
+ useOperatorToggle()
+ } header: {
+ Text("Operator")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
+ case let .accepted(acceptedAt):
+ if let acceptedAt = acceptedAt {
+ Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ case let .required(deadline):
+ if userServers[operatorIndex].operator_.enabled, let deadline = deadline {
+ Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+ }
+
+ if userServers[operatorIndex].operator_.enabled {
+ if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
+ .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
+ .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Use for messages")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+ }
+
+ // Preset servers can't be deleted
+ if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if srv.wrappedValue.preset {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ } header: {
+ Text("Message servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteSMPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Added message servers")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
+ .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Use for files")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+ }
+
+ // Preset servers can't be deleted
+ if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if srv.wrappedValue.preset {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ } header: {
+ Text("Media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteXFTPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Added media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ Section {
+ TestServersButton(
+ smpServers: $userServers[operatorIndex].smpServers,
+ xftpServers: $userServers[operatorIndex].xftpServers,
+ testing: $testing
+ )
+ }
+ }
+ }
+ }
+ .toolbar {
+ if (
+ !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty ||
+ !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty
+ ) {
+ EditButton()
+ }
+ }
+ .sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) {
+ SingleOperatorUsageConditionsView(
+ currUserServers: $currUserServers,
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ operatorIndex: operatorIndex
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ private func infoViewLink() -> some View {
+ NavigationLink() {
+ OperatorInfoView(serverOperator: userServers[operatorIndex].operator_)
+ .navigationBarTitle("Network operator")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ Image(userServers[operatorIndex].operator_.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0)
+ .frame(height: 40)
+ }
+ }
+
+ private func useOperatorToggle() -> some View {
+ Toggle("Use servers", isOn: $useOperator)
+ .onChange(of: useOperator) { useOperatorToggle in
+ if useOperatorToggleReset {
+ useOperatorToggleReset = false
+ } else if useOperatorToggle {
+ switch userServers[operatorIndex].operator_.conditionsAcceptance {
+ case .accepted:
+ userServers[operatorIndex].operator_.enabled = true
+ validateServers_($userServers, $serverErrors)
+ case let .required(deadline):
+ if deadline == nil {
+ showConditionsSheet = true
+ } else {
+ userServers[operatorIndex].operator_.enabled = true
+ validateServers_($userServers, $serverErrors)
+ }
+ }
+ } else {
+ userServers[operatorIndex].operator_.enabled = false
+ validateServers_($userServers, $serverErrors)
+ }
+ }
+ }
+
+ private func onUseToggleSheetDismissed() {
+ if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed {
+ useOperatorToggleReset = true
+ useOperator = false
+ }
+ }
+}
+
+func conditionsTimestamp(_ date: Date) -> String {
+ let localDateFormatter = DateFormatter()
+ localDateFormatter.dateStyle = .medium
+ localDateFormatter.timeStyle = .none
+ return localDateFormatter.string(from: date)
+}
+
+struct OperatorInfoView: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ var serverOperator: ServerOperator
+
+ var body: some View {
+ VStack {
+ List {
+ Section {
+ VStack(alignment: .leading) {
+ Image(serverOperator.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ if let legalName = serverOperator.legalName {
+ Text(legalName)
+ }
+ }
+ }
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(serverOperator.info.description, id: \.self) { d in
+ Text(d)
+ }
+ }
+ Link(serverOperator.info.website.absoluteString, destination: serverOperator.info.website)
+ }
+ if let selfhost = serverOperator.info.selfhost {
+ Section {
+ Link(selfhost.text, destination: selfhost.link)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ConditionsTextView: View {
+ @State private var conditionsData: (UsageConditions, String?, UsageConditions?)?
+ @State private var failedToLoad: Bool = false
+ @State private var conditionsHTML: String? = nil
+
+ let defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
+
+ var body: some View {
+ viewBody()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .task {
+ do {
+ let conditions = try await getUsageConditions()
+ let conditionsText = conditions.1
+ let parentLink = "https://github.com/simplex-chat/simplex-chat/blob/\(conditions.0.conditionsCommit)"
+ let preparedText: String?
+ if let conditionsText {
+ let prepared = prepareMarkdown(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines), parentLink)
+ conditionsHTML = MarkdownParser().html(from: prepared)
+ preparedText = prepared
+ } else {
+ preparedText = nil
+ }
+ conditionsData = (conditions.0, preparedText, conditions.2)
+ } catch let error {
+ logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))")
+ failedToLoad = true
+ }
+ }
+ }
+
+ // TODO Diff rendering
+ @ViewBuilder private func viewBody() -> some View {
+ if let (usageConditions, _, _) = conditionsData {
+ if let conditionsHTML {
+ ConditionsWebView(html: conditionsHTML)
+ .padding(6)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(uiColor: .secondarySystemGroupedBackground))
+ )
+ } else {
+ let conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/\(usageConditions.conditionsCommit)/PRIVACY.md"
+ conditionsLinkView(conditionsLink)
+ }
+ } else if failedToLoad {
+ conditionsLinkView(defaultConditionsLink)
+ } else {
+ ProgressView()
+ .scaleEffect(2)
+ }
+ }
+
+ private func conditionsLinkView(_ conditionsLink: String) -> some View {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Current conditions text couldn't be loaded, you can review conditions via this link:")
+ Link(destination: URL(string: conditionsLink)!) {
+ Text(conditionsLink)
+ .multilineTextAlignment(.leading)
+ }
+ }
+ }
+
+ private func prepareMarkdown(_ text: String, _ parentLink: String) -> String {
+ let localLinkRegex = try! NSRegularExpression(pattern: "\\[([^\\(]*)\\]\\(#.*\\)")
+ let h1Regex = try! NSRegularExpression(pattern: "^# ")
+ var text = localLinkRegex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "$1")
+ text = h1Regex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "")
+ return text
+ .replacingOccurrences(of: "](/", with: "](\(parentLink)/")
+ .replacingOccurrences(of: "](./", with: "](\(parentLink)/")
+ }
+}
+
+struct SingleOperatorUsageConditionsView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @Binding var currUserServers: [UserOperatorServers]
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var operatorIndex: Int
+
+ var body: some View {
+ viewBody()
+ }
+
+ @ViewBuilder private func viewBody() -> some View {
+ let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
+ if case .accepted = userServers[operatorIndex].operator_.conditionsAcceptance {
+
+ // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
+ VStack(alignment: .leading, spacing: 20) {
+ viewHeader()
+ ConditionsTextView()
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+
+ } else if !operatorsWithConditionsAccepted.isEmpty {
+
+ NavigationView {
+ VStack(alignment: .leading, spacing: 20) {
+ viewHeader()
+ Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
+ Text("The same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.")
+ conditionsAppliedToOtherOperatorsText()
+ Spacer()
+
+ acceptConditionsButton()
+ usageConditionsNavLinkButton()
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+ }
+
+ } else {
+
+ VStack(alignment: .leading, spacing: 20) {
+ viewHeader()
+ Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.")
+ conditionsAppliedToOtherOperatorsText()
+ ConditionsTextView()
+ acceptConditionsButton()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+
+ }
+ }
+
+ private func viewHeader() -> some View {
+ HStack {
+ Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold()
+ Spacer()
+ conditionsLinkButton()
+ }
+ .padding(.top)
+ .padding(.top)
+ }
+
+ @ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View {
+ let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter {
+ $0.enabled &&
+ !$0.conditionsAcceptance.conditionsAccepted &&
+ $0.operatorId != userServers[operatorIndex].operator_.operatorId
+ }
+ if !otherOperatorsToApply.isEmpty {
+ Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.")
+ }
+ }
+
+ @ViewBuilder private func acceptConditionsButton() -> some View {
+ let operatorIds = ChatModel.shared.conditions.serverOperators
+ .filter {
+ $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator
+ ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted
+ }
+ .map { $0.operatorId }
+ Button {
+ acceptForOperators(operatorIds, operatorIndex)
+ } label: {
+ Text("Accept conditions")
+ }
+ .buttonStyle(OnboardingButtonStyle())
+ }
+
+ func acceptForOperators(_ operatorIds: [Int64], _ operatorIndexToEnable: Int) {
+ Task {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
+ await MainActor.run {
+ ChatModel.shared.conditions = r
+ updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
+ updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
+ userServers[operatorIndexToEnable].operator?.enabled = true
+ validateServers_($userServers, $serverErrors)
+ dismiss()
+ }
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting conditions", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ }
+
+ private func usageConditionsNavLinkButton() -> some View {
+ NavigationLink("View conditions") {
+ ConditionsTextView()
+ .padding()
+ .navigationTitle("Conditions of use")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .font(.callout)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+}
+
+func conditionsLinkButton() -> some View {
+ let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
+ let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL
+ return Menu {
+ Link(destination: mdUrl) {
+ Label("Open conditions", systemImage: "doc")
+ }
+ if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
+ Link(destination: commitUrl) {
+ Label("Open changes", systemImage: "ellipsis")
+ }
+ }
+ } label: {
+ Image(systemName: "arrow.up.right.circle")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20)
+ .padding(2)
+ .contentShape(Circle())
+ }
+}
+
+#Preview {
+ OperatorView(
+ currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
+ userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([]),
+ operatorIndex: 1,
+ useOperator: ServerOperator.sampleData1.enabled
+ )
+}
diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
similarity index 70%
rename from apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift
rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
index da29dfac29..13d01874ed 100644
--- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
@@ -12,15 +12,15 @@ import SimpleXChat
struct ProtocolServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
- let serverProtocol: ServerProtocol
- @Binding var server: ServerCfg
- @State var serverToEdit: ServerCfg
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ @Binding var server: UserServer
+ @State var serverToEdit: UserServer
+ var backLabel: LocalizedStringKey
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
- var proto: String { serverProtocol.rawValue.uppercased() }
-
var body: some View {
ZStack {
if server.preset {
@@ -32,9 +32,33 @@ struct ProtocolServerView: View {
ProgressView().scaleEffect(2)
}
}
- .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
- server = serverToEdit
- dismiss()
+ .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
+ if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
+ let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) {
+ if serverToEditProtocol != serverProtocol {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Error updating server", comment: "alert title"),
+ message: NSLocalizedString("Server protocol changed.", comment: "alert title")
+ )
+ } else if serverToEditOperator != serverOperator {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Error updating server", comment: "alert title"),
+ message: NSLocalizedString("Server operator changed.", comment: "alert title")
+ )
+ } else {
+ server = serverToEdit
+ validateServers_($userServers, $serverErrors)
+ dismiss()
+ }
+ } else {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Invalid server address!", comment: "alert title"),
+ message: NSLocalizedString("Check server address and try again.", comment: "alert title")
+ )
+ }
})
.alert(isPresented: $showTestFailure) {
Alert(
@@ -62,7 +86,7 @@ struct ProtocolServerView: View {
private func customServer() -> some View {
VStack {
let serverAddress = parseServerAddress(serverToEdit.server)
- let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
+ let valid = serverAddress?.valid == true
List {
Section {
TextEditor(text: $serverToEdit.server)
@@ -112,10 +136,7 @@ struct ProtocolServerView: View {
Spacer()
showTestStatus(server: serverToEdit)
}
- let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
- .disabled(useForNewDisabled)
- .foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
}
}
}
@@ -142,7 +163,7 @@ struct BackButton: ViewModifier {
}
}
-@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
+@ViewBuilder func showTestStatus(server: UserServer) -> some View {
switch server.tested {
case .some(true):
Image(systemName: "checkmark")
@@ -155,7 +176,7 @@ struct BackButton: ViewModifier {
}
}
-func testServerConnection(server: Binding) async -> ProtocolTestFailure? {
+func testServerConnection(server: Binding) async -> ProtocolTestFailure? {
do {
let r = try await testProtoServer(server: server.wrappedValue.server)
switch r {
@@ -178,9 +199,11 @@ func testServerConnection(server: Binding) async -> ProtocolTestFailu
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServerView(
- serverProtocol: .smp,
- server: Binding.constant(ServerCfg.sampleData.custom),
- serverToEdit: ServerCfg.sampleData.custom
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([]),
+ server: Binding.constant(UserServer.sampleData.custom),
+ serverToEdit: UserServer.sampleData.custom,
+ backLabel: "Your SMP servers"
)
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
new file mode 100644
index 0000000000..ed3c5c773c
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
@@ -0,0 +1,359 @@
+//
+// ProtocolServersView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 15/11/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
+
+struct YourServersView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject private var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.editMode) private var editMode
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var operatorIndex: Int
+ @State private var selectedServer: String? = nil
+ @State private var showAddServer = false
+ @State private var newServerNavLinkActive = false
+ @State private var showScanProtoServer = false
+ @State private var testing = false
+
+ var body: some View {
+ yourServersView()
+ .opacity(testing ? 0.4 : 1)
+ .overlay {
+ if testing {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .allowsHitTesting(!testing)
+ }
+
+ @ViewBuilder private func yourServersView() -> some View {
+ let duplicateHosts = findDuplicateHosts(serverErrors)
+ List {
+ if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "Your servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteSMPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Message servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "Your servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteXFTPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ Section {
+ ZStack {
+ Button("Add server") {
+ showAddServer = true
+ }
+
+ NavigationLink(isActive: $newServerNavLinkActive) {
+ newServerDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ } footer: {
+ if let errStr = globalServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+
+ Section {
+ TestServersButton(
+ smpServers: $userServers[operatorIndex].smpServers,
+ xftpServers: $userServers[operatorIndex].xftpServers,
+ testing: $testing
+ )
+ howToButton()
+ }
+ }
+ .toolbar {
+ if (
+ !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
+ !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
+ ) {
+ EditButton()
+ }
+ }
+ .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
+ Button("Enter server manually") { newServerNavLinkActive = true }
+ Button("Scan server QR code") { showScanProtoServer = true }
+ }
+ .sheet(isPresented: $showScanProtoServer) {
+ ScanProtocolServer(
+ userServers: $userServers,
+ serverErrors: $serverErrors
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ private func newServerDestinationView() -> some View {
+ NewServerView(
+ userServers: $userServers,
+ serverErrors: $serverErrors
+ )
+ .navigationTitle("New server")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+
+ func howToButton() -> some View {
+ Button {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(howToUrl)
+ }
+ } label: {
+ HStack {
+ Text("How to use your servers")
+ Image(systemName: "arrow.up.right.circle")
+ }
+ }
+ }
+}
+
+struct ProtocolServerViewLink: View {
+ @EnvironmentObject var theme: AppTheme
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var duplicateHosts: Set
+ @Binding var server: UserServer
+ var serverProtocol: ServerProtocol
+ var backLabel: LocalizedStringKey
+ @Binding var selectedServer: String?
+
+ var body: some View {
+ let proto = serverProtocol.rawValue.uppercased()
+
+ NavigationLink(tag: server.id, selection: $selectedServer) {
+ ProtocolServerView(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ server: $server,
+ serverToEdit: server,
+ backLabel: backLabel
+ )
+ .navigationBarTitle("\(proto) server")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ let address = parseServerAddress(server.server)
+ HStack {
+ Group {
+ if let address = address {
+ if !address.valid || address.serverProtocol != serverProtocol {
+ invalidServer()
+ } else if address.hostnames.contains(where: duplicateHosts.contains) {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ } else if !server.enabled {
+ Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
+ } else {
+ showTestStatus(server: server)
+ }
+ } else {
+ invalidServer()
+ }
+ }
+ .frame(width: 16, alignment: .center)
+ .padding(.trailing, 4)
+
+ let v = Text(address?.hostnames.first ?? server.server).lineLimit(1)
+ if server.enabled {
+ v
+ } else {
+ v.foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+ }
+
+ private func invalidServer() -> some View {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+}
+
+func deleteSMPServer(
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ operatorServersIndex: Int,
+ _ serverIndexSet: IndexSet
+) {
+ if let idx = serverIndexSet.first {
+ let server = userServers[operatorServersIndex].wrappedValue.smpServers[idx]
+ if server.serverId == nil {
+ userServers[operatorServersIndex].wrappedValue.smpServers.remove(at: idx)
+ } else {
+ var updatedServer = server
+ updatedServer.deleted = true
+ userServers[operatorServersIndex].wrappedValue.smpServers[idx] = updatedServer
+ }
+ }
+}
+
+func deleteXFTPServer(
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ operatorServersIndex: Int,
+ _ serverIndexSet: IndexSet
+) {
+ if let idx = serverIndexSet.first {
+ let server = userServers[operatorServersIndex].wrappedValue.xftpServers[idx]
+ if server.serverId == nil {
+ userServers[operatorServersIndex].wrappedValue.xftpServers.remove(at: idx)
+ } else {
+ var updatedServer = server
+ updatedServer.deleted = true
+ userServers[operatorServersIndex].wrappedValue.xftpServers[idx] = updatedServer
+ }
+ }
+}
+
+struct TestServersButton: View {
+ @Binding var smpServers: [UserServer]
+ @Binding var xftpServers: [UserServer]
+ @Binding var testing: Bool
+
+ var body: some View {
+ Button("Test servers", action: testServers)
+ .disabled(testing || allServersDisabled)
+ }
+
+ private var allServersDisabled: Bool {
+ smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled }
+ }
+
+ private func testServers() {
+ resetTestStatus()
+ testing = true
+ Task {
+ let fs = await runServersTest()
+ await MainActor.run {
+ testing = false
+ if !fs.isEmpty {
+ let msg = fs.map { (srv, f) in
+ "\(srv): \(f.localizedDescription)"
+ }.joined(separator: "\n")
+ showAlert(
+ NSLocalizedString("Tests failed!", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg)
+ )
+ }
+ }
+ }
+ }
+
+ private func resetTestStatus() {
+ for i in 0.. [String: ProtocolTestFailure] {
+ var fs: [String: ProtocolTestFailure] = [:]
+ for i in 0..) {
switch resp {
case let .success(r):
- if parseServerAddress(r.string) != nil {
- servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
- dismiss()
- } else {
- showAddressError = true
- }
+ var server: UserServer = .empty
+ server.server = r.string
+ addServer(server, $userServers, $serverErrors, dismiss)
case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
@@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View {
- ScanProtocolServer(servers: Binding.constant([]))
+ ScanProtocolServer(
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([])
+ )
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
index b9c92c9919..4e7f826f4f 100644
--- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
@@ -237,9 +237,17 @@ struct NotificationsView: View {
func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode {
- case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)."
- case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have."
- case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from."
+ case .off: return "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app."
+ case .periodic: return "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata."
+ case .instant: return "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from."
+ }
+}
+
+func ntfModeShortDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
+ switch mode {
+ case .off: return "Check messages when allowed."
+ case .periodic: return "Check messages every 20 min."
+ case .instant: return "E2E encrypted notifications."
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
index 0c10da2103..bd8171623a 100644
--- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
@@ -32,6 +32,17 @@ struct PreferencesView: View {
.disabled(currentPreferences == preferences)
}
}
+ .onDisappear {
+ if currentPreferences != preferences {
+ showAlert(
+ title: NSLocalizedString("Your chat preferences", comment: "alert title"),
+ message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save", comment: "alert button"),
+ buttonAction: savePreferences,
+ cancelButton: true
+ )
+ }
+ }
}
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding) -> some View {
diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift
deleted file mode 100644
index 0fb37d5c49..0000000000
--- a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift
+++ /dev/null
@@ -1,359 +0,0 @@
-//
-// ProtocolServersView.swift
-// SimpleX (iOS)
-//
-// Created by Evgeny on 15/11/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
-
-struct ProtocolServersView: View {
- @Environment(\.dismiss) var dismiss: DismissAction
- @EnvironmentObject private var m: ChatModel
- @EnvironmentObject var theme: AppTheme
- @Environment(\.editMode) private var editMode
- let serverProtocol: ServerProtocol
- @State private var currServers: [ServerCfg] = []
- @State private var presetServers: [ServerCfg] = []
- @State private var configuredServers: [ServerCfg] = []
- @State private var otherServers: [ServerCfg] = []
- @State private var selectedServer: String? = nil
- @State private var showAddServer = false
- @State private var showScanProtoServer = false
- @State private var justOpened = true
- @State private var testing = false
- @State private var alert: ServerAlert? = nil
- @State private var showSaveDialog = false
-
- var proto: String { serverProtocol.rawValue.uppercased() }
-
- var body: some View {
- ZStack {
- protocolServersView()
- if testing {
- ProgressView().scaleEffect(2)
- }
- }
- }
-
- enum ServerAlert: Identifiable {
- case testsFailed(failures: [String: ProtocolTestFailure])
- case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
-
- var id: String {
- switch self {
- case .testsFailed: return "testsFailed"
- case let .error(title, _): return "error \(title)"
- }
- }
- }
-
- private func protocolServersView() -> some View {
- List {
- if !configuredServers.isEmpty {
- Section {
- ForEach($configuredServers) { srv in
- protocolServerView(srv)
- }
- .onMove { indexSet, offset in
- configuredServers.move(fromOffsets: indexSet, toOffset: offset)
- }
- .onDelete { indexSet in
- configuredServers.remove(atOffsets: indexSet)
- }
- } header: {
- Text("Configured \(proto) servers")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
- .foregroundColor(theme.colors.secondary)
- .lineLimit(10)
- }
- }
-
- if !otherServers.isEmpty {
- Section {
- ForEach($otherServers) { srv in
- protocolServerView(srv)
- }
- .onMove { indexSet, offset in
- otherServers.move(fromOffsets: indexSet, toOffset: offset)
- }
- .onDelete { indexSet in
- otherServers.remove(atOffsets: indexSet)
- }
- } header: {
- Text("Other \(proto) servers")
- .foregroundColor(theme.colors.secondary)
- }
- }
-
- Section {
- Button("Add server") {
- showAddServer = true
- }
- }
-
- Section {
- Button("Reset") { partitionServers(currServers) }
- .disabled(Set(allServers) == Set(currServers) || testing)
- Button("Test servers", action: testServers)
- .disabled(testing || allServersDisabled)
- Button("Save servers", action: saveServers)
- .disabled(saveDisabled)
- howToButton()
- }
- }
- .toolbar { EditButton() }
- .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
- Button("Enter server manually") {
- otherServers.append(ServerCfg.empty)
- selectedServer = allServers.last?.id
- }
- Button("Scan server QR code") { showScanProtoServer = true }
- Button("Add preset servers", action: addAllPresets)
- .disabled(hasAllPresets())
- }
- .sheet(isPresented: $showScanProtoServer) {
- ScanProtocolServer(servers: $otherServers)
- .modifier(ThemedBackground(grouped: true))
- }
- .modifier(BackButton(disabled: Binding.constant(false)) {
- if saveDisabled {
- dismiss()
- justOpened = false
- } else {
- showSaveDialog = true
- }
- })
- .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
- Button("Save") {
- saveServers()
- dismiss()
- justOpened = false
- }
- Button("Exit without saving") { dismiss() }
- }
- .alert(item: $alert) { a in
- switch a {
- case let .testsFailed(fs):
- let msg = fs.map { (srv, f) in
- "\(srv): \(f.localizedDescription)"
- }.joined(separator: "\n")
- return Alert(
- title: Text("Tests failed!"),
- message: Text("Some servers failed the test:\n" + msg)
- )
- case .error:
- return Alert(
- title: Text("Error")
- )
- }
- }
- .onAppear {
- // this condition is needed to prevent re-setting the servers when exiting single server view
- if justOpened {
- do {
- let r = try getUserProtoServers(serverProtocol)
- currServers = r.protoServers
- presetServers = r.presetServers
- partitionServers(currServers)
- } catch let error {
- alert = .error(
- title: "Error loading \(proto) servers",
- error: "Error: \(responseError(error))"
- )
- }
- justOpened = false
- } else {
- partitionServers(allServers)
- }
- }
- }
-
- private func partitionServers(_ servers: [ServerCfg]) {
- configuredServers = servers.filter { $0.preset || $0.enabled }
- otherServers = servers.filter { !($0.preset || $0.enabled) }
- }
-
- private var allServers: [ServerCfg] {
- configuredServers + otherServers
- }
-
- private var saveDisabled: Bool {
- allServers.isEmpty ||
- Set(allServers) == Set(currServers) ||
- testing ||
- !allServers.allSatisfy { srv in
- if let address = parseServerAddress(srv.server) {
- return uniqueAddress(srv, address)
- }
- return false
- } ||
- allServersDisabled
- }
-
- private var allServersDisabled: Bool {
- allServers.allSatisfy { !$0.enabled }
- }
-
- private func protocolServerView(_ server: Binding) -> some View {
- let srv = server.wrappedValue
- return NavigationLink(tag: srv.id, selection: $selectedServer) {
- ProtocolServerView(
- serverProtocol: serverProtocol,
- server: server,
- serverToEdit: srv
- )
- .navigationBarTitle(srv.preset ? "Preset server" : "Your server")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- let address = parseServerAddress(srv.server)
- HStack {
- Group {
- if let address = address {
- if !address.valid || address.serverProtocol != serverProtocol {
- invalidServer()
- } else if !uniqueAddress(srv, address) {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
- } else if !srv.enabled {
- Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
- } else {
- showTestStatus(server: srv)
- }
- } else {
- invalidServer()
- }
- }
- .frame(width: 16, alignment: .center)
- .padding(.trailing, 4)
-
- let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
- if srv.enabled {
- v
- } else {
- v.foregroundColor(theme.colors.secondary)
- }
- }
- }
- }
-
- func howToButton() -> some View {
- Button {
- DispatchQueue.main.async {
- UIApplication.shared.open(howToUrl)
- }
- } label: {
- HStack {
- Text("How to use your servers")
- Image(systemName: "arrow.up.right.circle")
- }
- }
- }
-
- private func invalidServer() -> some View {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
- }
-
- private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
- allServers.allSatisfy { srv in
- address.hostnames.allSatisfy { host in
- srv.id == s.id || !srv.server.contains(host)
- }
- }
- }
-
- private func hasAllPresets() -> Bool {
- presetServers.allSatisfy { hasPreset($0) }
- }
-
- private func addAllPresets() {
- for srv in presetServers {
- if !hasPreset(srv) {
- configuredServers.append(srv)
- }
- }
- }
-
- private func hasPreset(_ srv: ServerCfg) -> Bool {
- allServers.contains(where: { $0.server == srv.server })
- }
-
- private func testServers() {
- resetTestStatus()
- testing = true
- Task {
- let fs = await runServersTest()
- await MainActor.run {
- testing = false
- if !fs.isEmpty {
- alert = .testsFailed(failures: fs)
- }
- }
- }
- }
-
- private func resetTestStatus() {
- for i in 0.. [String: ProtocolTestFailure] {
- var fs: [String: ProtocolTestFailure] = [:]
- for i in 0..(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] )
@@ -245,13 +259,14 @@ public class CodableDefault {
}
}
+let networkProxyDefault: CodableDefault = CodableDefault(defaults: UserDefaults.standard, forKey: DEFAULT_NETWORK_PROXY, withDefault: NetworkProxy.def)
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
+ @Environment(\.dismiss) var dismiss
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var sceneDelegate: SceneDelegate
@EnvironmentObject var theme: AppTheme
- @Binding var showSettings: Bool
@State private var showProgress: Bool = false
var body: some View {
@@ -260,71 +275,12 @@ struct SettingsView: View {
if showProgress {
progressView()
}
- if let la = chatModel.laRequest {
- LocalAuthView(authRequest: la)
- }
}
}
@ViewBuilder func settingsView() -> some View {
let user = chatModel.currentUser
- NavigationView {
List {
- Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
- if let user = user {
- NavigationLink {
- UserProfile()
- .navigationTitle("Your current profile")
- .modifier(ThemedBackground())
- } label: {
- ProfilePreview(profileOf: user)
- .padding(.leading, -8)
- }
- }
-
- NavigationLink {
- UserProfilesView(showSettings: $showSettings)
- } label: {
- settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") }
- }
-
-
- if let user = user {
- NavigationLink {
- UserAddressView(shareViaProfile: user.addressShared)
- .navigationTitle("SimpleX address")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") }
- }
-
- NavigationLink {
- PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
- .navigationTitle("Your preferences")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") }
- }
- }
-
- NavigationLink {
- ConnectDesktopView(viaSettings: true)
- } label: {
- settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") }
- }
-
- NavigationLink {
- MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
- .navigationTitle("Migrate device")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
- }
- }
- .disabled(chatModel.chatRunning != true)
-
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink {
NotificationsView()
@@ -375,14 +331,29 @@ struct SettingsView: View {
}
.disabled(chatModel.chatRunning != true)
}
-
- chatDatabaseRow()
}
+ Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
+ chatDatabaseRow()
+ NavigationLink {
+ MigrateFromDevice(showProgressOnSettings: $showProgress)
+ .toolbar {
+ // Redaction broken for `.navigationTitle` - using a toolbar item instead.
+ ToolbarItem(placement: .principal) {
+ Text("Migrate device").font(.headline)
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
+ }
+ }
+
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
- ChatHelp(showSettings: $showSettings)
+ ChatHelp(dismissSettingsSheet: dismiss)
.navigationTitle("Welcome \(user.displayName)!")
.modifier(ThemedBackground())
.frame(maxHeight: .infinity, alignment: .top)
@@ -391,7 +362,7 @@ struct SettingsView: View {
}
}
NavigationLink {
- WhatsNewView(viaSettings: true)
+ WhatsNewView(viaSettings: true, updatedConditions: false)
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.inline)
} label: {
@@ -407,7 +378,7 @@ struct SettingsView: View {
}
settingsRow("number", color: theme.colors.secondary) {
Button("Send questions and ideas") {
- showSettings = false
+ dismiss()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
}
@@ -456,16 +427,15 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
.modifier(ThemedBackground(grouped: true))
- }
- .onDisappear {
- chatModel.showingTerminal = false
- chatModel.terminalItems = []
- }
+ .onDisappear {
+ chatModel.showingTerminal = false
+ chatModel.terminalItems = []
+ }
}
private func chatDatabaseRow() -> some View {
NavigationLink {
- DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
+ DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL)
.navigationTitle("Your chat database")
.modifier(ThemedBackground(grouped: true))
} label: {
@@ -543,26 +513,25 @@ struct ProfilePreview: View {
HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6)
- .padding(.vertical, 6)
- VStack(alignment: .leading) {
- Text(profileOf.displayName)
- .fontWeight(.bold)
- .font(.title2)
- if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
- Text(profileOf.fullName)
- }
- }
+ profileName(profileOf).lineLimit(1)
}
}
}
+func profileName(_ profileOf: NamedChat) -> Text {
+ var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
+ if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
+ t = t + Text(" (" + profileOf.fullName + ")")
+// .font(.callout)
+ }
+ return t
+}
+
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
- @State var showSettings = false
-
- return SettingsView(showSettings: $showSettings)
+ return SettingsView()
.environmentObject(chatModel)
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
index 15f6a1c7d7..6c1ea8deb2 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
@@ -9,15 +9,94 @@
import SwiftUI
struct UserAddressLearnMore: View {
+ @State var showCreateAddressButton = false
+ @State private var createAddressLinkActive = false
+ @State private var createOneTimeLinkActive = false
+
var body: some View {
- List {
- VStack(alignment: .leading, spacing: 18) {
- Text("You can share your address as a link or QR code - anybody can connect to you.")
- Text("You won't lose your contacts if you later delete your address.")
- Text("When people request to connect, you can accept or reject it.")
- Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
+ VStack {
+ List {
+ VStack(alignment: .leading, spacing: 12) {
+ (Text(Image(systemName: "envelope")).foregroundColor(.secondary) + textSpace + Text("Share address publicly").bold().font(.title2))
+ Text("Share SimpleX address on social media.")
+ Text("You won't lose your contacts if you later delete your address.")
+
+ (Text(Image(systemName: "link.badge.plus")).foregroundColor(.secondary) + textSpace + Text("Share 1-time link with a friend").font(.title2).bold())
+ .padding(.top)
+ Text("1-time link can be used *with one contact only* - share in person or via any messenger.")
+ Text("You can set connection name, to remember who the link was shared with.")
+
+ if !showCreateAddressButton {
+ (Text(Image(systemName: "shield")).foregroundColor(.secondary) + textSpace + Text("Connection security").font(.title2).bold())
+ .padding(.top)
+ Text("SimpleX address and 1-time links are safe to share via any messenger.")
+ Text("To protect against your link being replaced, you can compare contact security codes.")
+ Text("Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).")
+ .padding(.top)
+ }
+
+ }
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
- .listRowBackground(Color.clear)
+ .frame(maxHeight: .infinity, alignment: .top)
+
+ Spacer()
+
+ if showCreateAddressButton {
+ VStack {
+ addressCreationButton()
+ .padding(.bottom)
+
+ createOneTimeLinkButton()
+ }
+ .padding()
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ private func addressCreationButton() -> some View {
+ ZStack {
+ Button {
+ createAddressLinkActive = true
+ } label: {
+ Text("Create SimpleX address")
+ }
+ .buttonStyle(OnboardingButtonStyle())
+
+ NavigationLink(isActive: $createAddressLinkActive) {
+ UserAddressView(autoCreate: true)
+ .navigationTitle("SimpleX address")
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ }
+
+ private func createOneTimeLinkButton() -> some View {
+ ZStack {
+ Button {
+ createOneTimeLinkActive = true
+ } label: {
+ Text("Create 1-time link")
+ .font(.callout)
+ }
+
+ NavigationLink(isActive: $createOneTimeLinkActive) {
+ NewChatView(selection: .invite)
+ .navigationTitle("New chat")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index fa95c51d36..7965215b49 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -14,56 +14,33 @@ struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
- @State var viaCreateLinkView = false
@State var shareViaProfile = false
+ @State var autoCreate = false
@State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState()
- @State private var ignoreShareViaProfileChange = false
@State private var showMailView = false
@State private var mailViewResult: Result? = nil
@State private var alert: UserAddressAlert?
- @State private var showSaveDialogue = false
@State private var progressIndicator = false
- @FocusState private var keyboardVisible: Bool
private enum UserAddressAlert: Identifiable {
case deleteAddress
- case profileAddress(on: Bool)
case shareOnCreate
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
case .deleteAddress: return "deleteAddress"
- case let .profileAddress(on): return "profileAddress \(on)"
case .shareOnCreate: return "shareOnCreate"
case let .error(title, _): return "error \(title)"
}
}
}
-
+
var body: some View {
ZStack {
- if viaCreateLinkView {
- userAddressScrollView()
- } else {
- userAddressScrollView()
- .modifier(BackButton(disabled: Binding.constant(false)) {
- if savedAAS == aas {
- dismiss()
- } else {
- keyboardVisible = false
- showSaveDialogue = true
- }
- })
- .confirmationDialog("Save settings?", isPresented: $showSaveDialogue) {
- Button("Save auto-accept settings") {
- saveAAS()
- dismiss()
- }
- Button("Exit without saving") { dismiss() }
- }
- }
+ userAddressView()
+
if progressIndicator {
ZStack {
if chatModel.userAddress != nil {
@@ -76,22 +53,10 @@ struct UserAddressView: View {
}
}
}
- }
-
- @Namespace private var bottomID
-
- private func userAddressScrollView() -> some View {
- ScrollViewReader { proxy in
- userAddressView()
- .onChange(of: keyboardVisible) { _ in
- if keyboardVisible {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- withAnimation {
- proxy.scrollTo(bottomID, anchor: .top)
- }
- }
- }
- }
+ .onAppear {
+ if chatModel.userAddress == nil, autoCreate {
+ createAddress()
+ }
}
}
@@ -103,14 +68,18 @@ struct UserAddressView: View {
aas = AutoAcceptState(userAddress: userAddress)
savedAAS = aas
}
- .onChange(of: aas.enable) { _ in
- if !aas.enable { aas = AutoAcceptState() }
- }
} else {
Section {
createAddressButton()
- } footer: {
- Text("Create an address to let people connect with you.")
+ } header: {
+ Text("For social media")
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Section {
+ createOneTimeLinkButton()
+ } header: {
+ Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
@@ -126,8 +95,8 @@ struct UserAddressView: View {
title: Text("Delete address?"),
message:
shareViaProfile
- ? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
- : Text("All your contacts will remain connected."),
+ ? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
+ : Text("All your contacts will remain connected."),
primaryButton: .destructive(Text("Delete")) {
progressIndicator = true
Task {
@@ -137,7 +106,6 @@ struct UserAddressView: View {
chatModel.userAddress = nil
chatModel.updateUser(u)
if shareViaProfile {
- ignoreShareViaProfileChange = true
shareViaProfile = false
}
}
@@ -150,37 +118,12 @@ struct UserAddressView: View {
}
}, secondaryButton: .cancel()
)
- case let .profileAddress(on):
- if on {
- return Alert(
- title: Text("Share address with contacts?"),
- message: Text("Profile update will be sent to your contacts."),
- primaryButton: .default(Text("Share")) {
- setProfileAddress(on)
- }, secondaryButton: .cancel() {
- ignoreShareViaProfileChange = true
- shareViaProfile = !on
- }
- )
- } else {
- return Alert(
- title: Text("Stop sharing address?"),
- message: Text("Profile update will be sent to your contacts."),
- primaryButton: .default(Text("Stop sharing")) {
- setProfileAddress(on)
- }, secondaryButton: .cancel() {
- ignoreShareViaProfileChange = true
- shareViaProfile = !on
- }
- )
- }
case .shareOnCreate:
return Alert(
title: Text("Share address with contacts?"),
message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."),
primaryButton: .default(Text("Share")) {
- setProfileAddress(true)
- ignoreShareViaProfileChange = true
+ setProfileAddress($progressIndicator, true)
shareViaProfile = true
}, secondaryButton: .cancel()
)
@@ -195,19 +138,39 @@ struct UserAddressView: View {
SimpleXLinkQRCode(uri: userAddress.connReqContact)
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
shareQRCodeButton(userAddress)
- if MFMailComposeViewController.canSendMail() {
- shareViaEmailButton(userAddress)
+ // if MFMailComposeViewController.canSendMail() {
+ // shareViaEmailButton(userAddress)
+ // }
+ settingsRow("briefcase", color: theme.colors.secondary) {
+ Toggle("Business address", isOn: $aas.business)
+ .onChange(of: aas.business) { ba in
+ if ba {
+ aas.enable = true
+ aas.incognito = false
+ }
+ saveAAS($aas, $savedAAS)
+ }
}
- shareWithContactsButton()
- autoAcceptToggle()
- learnMoreButton()
+ addressSettingsButton(userAddress)
} header: {
- Text("Address")
+ Text("For social media")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if aas.business {
+ Text("Add your team members to the conversations.")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ Section {
+ createOneTimeLinkButton()
+ } header: {
+ Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
- if aas.enable {
- autoAcceptSection()
+ Section {
+ learnMoreButton()
}
Section {
@@ -216,32 +179,47 @@ struct UserAddressView: View {
Text("Your contacts will remain connected.")
.foregroundColor(theme.colors.secondary)
}
- .id(bottomID)
}
private func createAddressButton() -> some View {
Button {
- progressIndicator = true
- Task {
- do {
- let connReqContact = try await apiCreateUserAddress()
- DispatchQueue.main.async {
- chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
- alert = .shareOnCreate
- progressIndicator = false
- }
- } catch let error {
- logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
- let a = getErrorAlert(error, "Error creating address")
- alert = .error(title: a.title, error: a.message)
- await MainActor.run { progressIndicator = false }
- }
- }
+ createAddress()
} label: {
Label("Create SimpleX address", systemImage: "qrcode")
}
}
+ private func createAddress() {
+ progressIndicator = true
+ Task {
+ do {
+ let connReqContact = try await apiCreateUserAddress()
+ DispatchQueue.main.async {
+ chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
+ alert = .shareOnCreate
+ progressIndicator = false
+ }
+ } catch let error {
+ logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
+ let a = getErrorAlert(error, "Error creating address")
+ alert = .error(title: a.title, error: a.message)
+ await MainActor.run { progressIndicator = false }
+ }
+ }
+ }
+
+ private func createOneTimeLinkButton() -> some View {
+ NavigationLink {
+ NewChatView(selection: .invite)
+ .navigationTitle("New chat")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Label("Create 1-time link", systemImage: "link.badge.plus")
+ .foregroundColor(theme.colors.primary)
+ }
+ }
+
private func deleteAddressButton() -> some View {
Button(role: .destructive) {
alert = .deleteAddress
@@ -291,24 +269,140 @@ struct UserAddressView: View {
}
}
- private func autoAcceptToggle() -> some View {
- settingsRow("checkmark", color: theme.colors.secondary) {
- Toggle("Auto-accept", isOn: $aas.enable)
- .onChange(of: aas.enable) { _ in
- saveAAS()
- }
+ private func addressSettingsButton(_ userAddress: UserContactLink) -> some View {
+ NavigationLink {
+ UserAddressSettingsView(shareViaProfile: $shareViaProfile)
+ .navigationTitle("Address settings")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("Address settings")
}
}
private func learnMoreButton() -> some View {
NavigationLink {
UserAddressLearnMore()
- .navigationTitle("SimpleX address")
+ .navigationTitle("Address or 1-time link?")
.modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
+ .navigationBarTitleDisplayMode(.inline)
} label: {
settingsRow("info.circle", color: theme.colors.secondary) {
- Text("About SimpleX address")
+ Text("SimpleX address or 1-time link?")
+ }
+ }
+ }
+}
+
+private struct AutoAcceptState: Equatable {
+ var enable = false
+ var incognito = false
+ var business = false
+ var welcomeText = ""
+
+ init(enable: Bool = false, incognito: Bool = false, business: Bool = false, welcomeText: String = "") {
+ self.enable = enable
+ self.incognito = incognito
+ self.business = business
+ self.welcomeText = welcomeText
+ }
+
+ init(userAddress: UserContactLink) {
+ if let aa = userAddress.autoAccept {
+ enable = true
+ incognito = aa.acceptIncognito
+ business = aa.businessAddress
+ if let msg = aa.autoReply {
+ welcomeText = msg.text
+ } else {
+ welcomeText = ""
+ }
+ } else {
+ enable = false
+ incognito = false
+ business = false
+ welcomeText = ""
+ }
+ }
+
+ var autoAccept: AutoAccept? {
+ if enable {
+ var autoReply: MsgContent? = nil
+ let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if s != "" { autoReply = .text(s) }
+ return AutoAccept(businessAddress: business, acceptIncognito: incognito, autoReply: autoReply)
+ }
+ return nil
+ }
+}
+
+private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) {
+ progressIndicator.wrappedValue = true
+ Task {
+ do {
+ if let u = try await apiSetProfileAddress(on: on) {
+ DispatchQueue.main.async {
+ ChatModel.shared.updateUser(u)
+ }
+ }
+ await MainActor.run { progressIndicator.wrappedValue = false }
+ } catch let error {
+ logger.error("apiSetProfileAddress: \(responseError(error))")
+ await MainActor.run { progressIndicator.wrappedValue = false }
+ }
+ }
+}
+
+struct UserAddressSettingsView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @Binding var shareViaProfile: Bool
+ @State private var aas = AutoAcceptState()
+ @State private var savedAAS = AutoAcceptState()
+ @State private var ignoreShareViaProfileChange = false
+ @State private var progressIndicator = false
+ @FocusState private var keyboardVisible: Bool
+
+ var body: some View {
+ ZStack {
+ if let userAddress = ChatModel.shared.userAddress {
+ userAddressSettingsView()
+ .onAppear {
+ aas = AutoAcceptState(userAddress: userAddress)
+ savedAAS = aas
+ }
+ .onChange(of: aas.enable) { aasEnabled in
+ if !aasEnabled { aas = AutoAcceptState() }
+ }
+ .onDisappear {
+ if savedAAS != aas {
+ showAlert(
+ title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
+ message: NSLocalizedString("Settings were changed.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save", comment: "alert button"),
+ buttonAction: { saveAAS($aas, $savedAAS) },
+ cancelButton: true
+ )
+ }
+ }
+ } else {
+ Text(String("Error opening address settings"))
+ }
+ if progressIndicator {
+ ProgressView().scaleEffect(2)
+ }
+ }
+ }
+
+ private func userAddressSettingsView() -> some View {
+ List {
+ Section {
+ shareWithContactsButton()
+ autoAcceptToggle().disabled(aas.business)
+ }
+
+ if aas.enable {
+ autoAcceptSection()
}
}
}
@@ -320,70 +414,70 @@ struct UserAddressView: View {
if ignoreShareViaProfileChange {
ignoreShareViaProfileChange = false
} else {
- alert = .profileAddress(on: on)
+ if on {
+ showAlert(
+ NSLocalizedString("Share address with contacts?", comment: "alert title"),
+ message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ ignoreShareViaProfileChange = true
+ shareViaProfile = !on
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Share", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ setProfileAddress($progressIndicator, on)
+ }
+ )
+ ]}
+ )
+ } else {
+ showAlert(
+ NSLocalizedString("Stop sharing address?", comment: "alert title"),
+ message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ ignoreShareViaProfileChange = true
+ shareViaProfile = !on
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Stop sharing", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ setProfileAddress($progressIndicator, on)
+ }
+ )
+ ]}
+ )
+ }
}
}
}
}
- private func setProfileAddress(_ on: Bool) {
- progressIndicator = true
- Task {
- do {
- if let u = try await apiSetProfileAddress(on: on) {
- DispatchQueue.main.async {
- chatModel.updateUser(u)
- }
+ private func autoAcceptToggle() -> some View {
+ settingsRow("checkmark", color: theme.colors.secondary) {
+ Toggle("Auto-accept", isOn: $aas.enable)
+ .onChange(of: aas.enable) { _ in
+ saveAAS($aas, $savedAAS)
}
- await MainActor.run { progressIndicator = false }
- } catch let error {
- logger.error("UserAddressView apiSetProfileAddress: \(responseError(error))")
- await MainActor.run { progressIndicator = false }
- }
}
}
- private struct AutoAcceptState: Equatable {
- var enable = false
- var incognito = false
- var welcomeText = ""
-
- init(enable: Bool = false, incognito: Bool = false, welcomeText: String = "") {
- self.enable = enable
- self.incognito = incognito
- self.welcomeText = welcomeText
- }
-
- init(userAddress: UserContactLink) {
- if let aa = userAddress.autoAccept {
- enable = true
- incognito = aa.acceptIncognito
- if let msg = aa.autoReply {
- welcomeText = msg.text
- } else {
- welcomeText = ""
- }
- } else {
- enable = false
- incognito = false
- welcomeText = ""
- }
- }
-
- var autoAccept: AutoAccept? {
- if enable {
- var autoReply: MsgContent? = nil
- let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
- if s != "" { autoReply = .text(s) }
- return AutoAccept(acceptIncognito: incognito, autoReply: autoReply)
- }
- return nil
- }
- }
-
- @ViewBuilder private func autoAcceptSection() -> some View {
+ private func autoAcceptSection() -> some View {
Section {
- acceptIncognitoToggle()
+ if !aas.business {
+ acceptIncognitoToggle()
+ }
welcomeMessageEditor()
saveAASButton()
.disabled(aas == savedAAS)
@@ -423,22 +517,24 @@ struct UserAddressView: View {
private func saveAASButton() -> some View {
Button {
keyboardVisible = false
- saveAAS()
+ saveAAS($aas, $savedAAS)
} label: {
Text("Save")
}
}
+}
- private func saveAAS() {
- Task {
- do {
- if let address = try await userAddressAutoAccept(aas.autoAccept) {
- chatModel.userAddress = address
- savedAAS = aas
+private func saveAAS(_ aas: Binding, _ savedAAS: Binding) {
+ Task {
+ do {
+ if let address = try await userAddressAutoAccept(aas.wrappedValue.autoAccept) {
+ await MainActor.run {
+ ChatModel.shared.userAddress = address
+ savedAAS.wrappedValue = aas.wrappedValue
}
- } catch let error {
- logger.error("userAddressAutoAccept error: \(responseError(error))")
}
+ } catch let error {
+ logger.error("userAddressAutoAccept error: \(responseError(error))")
}
}
}
@@ -447,6 +543,8 @@ struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
+
+
return Group {
UserAddressView()
.environmentObject(chatModel)
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 198fd495bd..8a70efbe82 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -11,8 +11,11 @@ import SimpleXChat
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
@State private var profile = Profile(displayName: "", fullName: "")
- @State private var editProfile = false
+ @State private var currentProfileHash: Int?
+ // Modals
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@@ -21,85 +24,86 @@ struct UserProfile: View {
@FocusState private var focusDisplayName
var body: some View {
- let user: User = chatModel.currentUser!
-
- return VStack(alignment: .leading) {
- Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
- .padding(.bottom)
-
- if editProfile {
- ZStack(alignment: .center) {
- ZStack(alignment: .topTrailing) {
+ List {
+ Group {
+ if profile.image != nil {
+ ZStack(alignment: .bottomTrailing) {
+ ZStack(alignment: .topTrailing) {
+ profileImageView(profile.image)
+ .onTapGesture { showChooseSource = true }
+ overlayButton("multiply", edge: .top) { profile.image = nil }
+ }
+ overlayButton("camera", edge: .bottom) { showChooseSource = true }
+ }
+ } else {
+ ZStack(alignment: .center) {
profileImageView(profile.image)
- if user.image != nil {
- Button {
- profile.image = nil
- } label: {
- Image(systemName: "multiply")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12)
- }
+ editImageButton { showChooseSource = true }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .padding(.top)
+ .contentShape(Rectangle())
+
+ Section {
+ HStack {
+ TextField("Enter your name…", text: $profile.displayName)
+ .focused($focusDisplayName)
+ if !validDisplayName(profile.displayName) {
+ Button {
+ alert = .invalidNameError(validName: mkValidName(profile.displayName))
+ } label: {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
}
-
- editImageButton { showChooseSource = true }
}
- .frame(maxWidth: .infinity, alignment: .center)
-
- VStack(alignment: .leading) {
- ZStack(alignment: .leading) {
- if !validNewProfileName(user) {
- Button {
- alert = .invalidNameError(validName: mkValidName(profile.displayName))
- } label: {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
- }
- } else {
- Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
- }
- profileNameTextEdit("Profile name", $profile.displayName)
- .focused($focusDisplayName)
- }
- .padding(.bottom)
- if showFullName(user) {
- profileNameTextEdit("Full name (optional)", $profile.fullName)
- .padding(.bottom)
- }
- HStack(spacing: 20) {
- Button("Cancel") { editProfile = false }
- Button("Save (and notify contacts)") { saveProfile() }
- .disabled(!canSaveProfile(user))
- }
+ if let user = chatModel.currentUser, showFullName(user) {
+ TextField("Full name (optional)", text: $profile.fullName)
}
- .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
- } else {
- ZStack(alignment: .center) {
- profileImageView(user.image)
- .onTapGesture { startEditingImage(user) }
+ } footer: {
+ Text("Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.")
+ }
- if user.image == nil {
- editImageButton { startEditingImage(user) }
- }
+ Section {
+ Button(action: getCurrentProfile) {
+ Text("Reset")
}
- .frame(maxWidth: .infinity, alignment: .center)
-
- VStack(alignment: .leading) {
- profileNameView("Profile name:", user.profile.displayName)
- if showFullName(user) {
- profileNameView("Full name:", user.profile.fullName)
- }
- Button("Edit") {
- profile = fromLocalProfile(user.profile)
- editProfile = true
- focusDisplayName = true
- }
+ .disabled(currentProfileHash == profile.hashValue)
+ Button(action: saveProfile) {
+ Text("Save (and notify contacts)")
}
- .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
+ .disabled(!canSaveProfile)
}
}
- .padding()
- .frame(maxHeight: .infinity, alignment: .top)
+ // Lifecycle
+ .onAppear {
+ getCurrentProfile()
+ }
+ .onDisappear {
+ if canSaveProfile {
+ showAlert(
+ title: NSLocalizedString("Save your profile?", comment: "alert title"),
+ message: NSLocalizedString("Your profile was changed. If you save it, the updated profile will be sent to all your contacts.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save (and notify contacts)", comment: "alert button"),
+ buttonAction: saveProfile,
+ cancelButton: true
+ )
+ }
+ }
+ .onChange(of: chosenImage) { image in
+ Task {
+ let resized: String? = if let image {
+ await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
+ } else {
+ nil
+ }
+ await MainActor.run { profile.image = resized }
+ }
+ }
+ // Modals
.confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -126,57 +130,49 @@ struct UserProfile: View {
}
}
}
- .onChange(of: chosenImage) { image in
- if let image = image {
- profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
- } else {
- profile.image = nil
- }
- }
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
}
- func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View {
- TextField(label, text: name)
- .padding(.leading, 32)
- }
-
- func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View {
- HStack {
- Text(label)
- Text(name).fontWeight(.bold)
- }
- .padding(.bottom)
- }
-
- func startEditingImage(_ user: User) {
- profile = fromLocalProfile(user.profile)
- editProfile = true
- showChooseSource = true
- }
-
- private func validNewProfileName(_ user: User) -> Bool {
- profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces))
+ @ViewBuilder
+ private func overlayButton(
+ _ systemName: String,
+ edge: Edge.Set,
+ action: @escaping () -> Void
+ ) -> some View {
+ Image(systemName: systemName)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(height: 12)
+ .foregroundColor(theme.colors.primary)
+ .padding(6)
+ .frame(width: 36, height: 36, alignment: .center)
+ .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5))
+ .clipShape(Circle())
+ .contentShape(Circle())
+ .padding([.trailing, edge], -12)
+ .onTapGesture(perform: action)
}
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)
+
+ private var canSaveProfile: Bool {
+ currentProfileHash != profile.hashValue &&
+ profile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
+ validDisplayName(profile.displayName)
}
- func saveProfile() {
+ private func saveProfile() {
+ focusDisplayName = false
Task {
do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
- DispatchQueue.main.async {
+ await MainActor.run {
chatModel.updateCurrentUser(newProfile)
- profile = newProfile
+ getCurrentProfile()
}
- editProfile = false
} else {
alert = .duplicateUserError
}
@@ -185,6 +181,13 @@ struct UserProfile: View {
}
}
}
+
+ private func getCurrentProfile() {
+ if let user = chatModel.currentUser {
+ profile = fromLocalProfile(user.profile)
+ currentProfileHash = profile.hashValue
+ }
+ }
}
func profileImageView(_ imageStr: String?) -> some View {
@@ -201,19 +204,3 @@ func editImageButton(action: @escaping () -> Void) -> some View {
.frame(width: 48)
}
}
-
-struct UserProfile_Previews: PreviewProvider {
- static var previews: some View {
- let chatModel1 = ChatModel()
- chatModel1.currentUser = User.sampleData
- let chatModel2 = ChatModel()
- chatModel2.currentUser = User.sampleData
- chatModel2.currentUser?.profile.image = "data:image/jpg;base64,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"
- return Group {
- UserProfile()
- .environmentObject(chatModel1)
- UserProfile()
- .environmentObject(chatModel2)
- }
- }
-}
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
index 160130bccc..7cd86ef1ef 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
@@ -9,7 +9,6 @@ import SimpleXChat
struct UserProfilesView: View {
@EnvironmentObject private var m: ChatModel
@EnvironmentObject private var theme: AppTheme
- @Binding var showSettings: Bool
@Environment(\.editMode) private var editMode
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
@@ -22,6 +21,7 @@ struct UserProfilesView: View {
@State private var profileHidden = false
@State private var profileAction: UserProfileAction?
@State private var actionPassword = ""
+ @State private var navigateToProfileCreate = false
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
@@ -56,17 +56,6 @@ struct UserProfilesView: View {
}
var body: some View {
- if authorized {
- userProfilesView()
- } else {
- Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
- .onAppear(perform: runAuth)
- }
- }
-
- private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) }
-
- private func userProfilesView() -> some View {
List {
if profileHidden {
Button {
@@ -78,12 +67,14 @@ struct UserProfilesView: View {
Section {
let users = filteredUsers()
let v = ForEach(users) { u in
- userView(u.user)
+ userView(u)
}
if #available(iOS 16, *) {
v.onDelete { indexSet in
if let i = indexSet.first {
- confirmDeleteUser(users[i].user)
+ withAuth {
+ confirmDeleteUser(users[i].user)
+ }
}
}
} else {
@@ -91,13 +82,22 @@ struct UserProfilesView: View {
}
if trimmedSearchTextOrPassword == "" {
- NavigationLink {
- CreateProfile()
- } label: {
+ NavigationLink(
+ destination: CreateProfile(),
+ isActive: $navigateToProfileCreate
+ ) {
Label("Add profile", systemImage: "plus")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: 38)
+ .padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAuth {
+ self.navigateToProfileCreate = true
+ }
+ }
+ .padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
}
- .frame(height: 44)
- .padding(.vertical, 4)
}
} footer: {
Text("Tap to activate profile.")
@@ -191,7 +191,25 @@ struct UserProfilesView: View {
private var visibleUsersCount: Int {
m.users.filter({ u in !u.user.hidden }).count
}
-
+
+ private func withAuth(_ action: @escaping () -> Void) {
+ if authorized {
+ action()
+ } else {
+ authenticate(
+ reason: NSLocalizedString("Change chat profiles", comment: "authentication reason")
+ ) { laResult in
+ switch laResult {
+ case .success, .unavailable:
+ authorized = true
+ AppSheetState.shared.scenePhaseActive = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: action)
+ case .failed: authorized = false
+ }
+ }
+ }
+ }
+
private func correctPassword(_ user: User, _ pwd: String) -> Bool {
if let ph = user.viewPwdHash {
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
@@ -215,8 +233,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("trash", color: theme.colors.secondary) {
Button("Delete chat profile", role: .destructive) {
- profileAction = nil
- Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
+ withAuth {
+ profileAction = nil
+ Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
+ }
}
.disabled(!actionEnabled(user))
}
@@ -233,8 +253,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("lock.open", color: theme.colors.secondary) {
Button("Unhide chat profile") {
- profileAction = nil
- setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
+ withAuth{
+ profileAction = nil
+ setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
+ }
}
.disabled(!actionEnabled(user))
}
@@ -257,11 +279,13 @@ struct UserProfilesView: View {
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
Button(title, role: .destructive) {
- if let user = userToDelete {
- if passwordEntryRequired(user) {
- profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
- } else {
- alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ withAuth {
+ if let user = userToDelete {
+ if passwordEntryRequired(user) {
+ profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ } else {
+ alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ }
}
}
}
@@ -285,7 +309,7 @@ struct UserProfilesView: View {
await MainActor.run {
onboardingStageDefault.set(.step1_SimpleXInfo)
m.onboardingStage = .step1_SimpleXInfo
- showSettings = false
+ dismissAllSheets()
}
}
} else {
@@ -303,30 +327,38 @@ struct UserProfilesView: View {
}
}
- @ViewBuilder private func userView(_ user: User) -> some View {
+ @ViewBuilder private func userView(_ userInfo: UserInfo) -> some View {
+ let user = userInfo.user
let v = Button {
Task {
do {
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
+ dismissAllSheets()
} catch {
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
}
}
} label: {
HStack {
- ProfileImage(imageStr: user.image, size: 44)
- .padding(.vertical, 4)
+ ProfileImage(imageStr: user.image, size: 38)
.padding(.trailing, 12)
Text(user.chatViewName)
Spacer()
if user.activeUser {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
- } else if user.hidden {
- Image(systemName: "lock").foregroundColor(theme.colors.secondary)
- } else if !user.showNtfs {
- Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else {
- Image(systemName: "checkmark").foregroundColor(.clear)
+ if userInfo.unreadCount > 0 {
+ UnreadBadge(userInfo: userInfo)
+ }
+ if user.hidden {
+ Image(systemName: "lock").foregroundColor(theme.colors.secondary)
+ } else if userInfo.unreadCount == 0 {
+ if !user.showNtfs {
+ Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
+ } else {
+ Image(systemName: "checkmark").foregroundColor(.clear)
+ }
+ }
}
}
}
@@ -334,30 +366,38 @@ struct UserProfilesView: View {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden {
Button("Unhide") {
- if passwordEntryRequired(user) {
- profileAction = .unhideUser(user: user)
- } else {
- setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
+ withAuth {
+ if passwordEntryRequired(user) {
+ profileAction = .unhideUser(user: user)
+ } else {
+ setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
+ }
}
}
.tint(.green)
} else {
if visibleUsersCount > 1 {
Button("Hide") {
- selectedUser = user
+ withAuth {
+ selectedUser = user
+ }
}
.tint(.gray)
}
Group {
if user.showNtfs {
Button("Mute") {
- setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
- try await apiMuteUser(user.userId)
+ withAuth {
+ setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
+ try await apiMuteUser(user.userId)
+ }
}
}
} else {
Button("Unmute") {
- setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
+ withAuth {
+ setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
+ }
}
}
}
@@ -369,7 +409,9 @@ struct UserProfilesView: View {
} else {
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
- confirmDeleteUser(user)
+ withAuth {
+ confirmDeleteUser(user)
+ }
}
}
}
@@ -406,8 +448,15 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
return hash
}
+public func correctPassword(_ user: User, _ pwd: String) -> Bool {
+ if let ph = user.viewPwdHash {
+ return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
+ }
+ return false
+}
+
struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View {
- UserProfilesView(showSettings: Binding.constant(true))
+ UserProfilesView()
}
}
diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
index 40481d81f1..ef91bb30fd 100644
--- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
+++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
@@ -187,23 +187,18 @@
)
No comment provided by engineer.
-
- **Add new contact**: to create your one-time QR Code or link for your contact.
- ** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بكم.
- No comment provided by engineer.
-
**Create link / QR code** for your contact to use.
** أنشئ رابطًا / رمز QR ** لتستخدمه جهة الاتصال الخاصة بك.
No comment provided by engineer.
-
- **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.
+
+ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.
** المزيد من الخصوصية **: تحققوا من الرسائل الجديدة كل 20 دقيقة. تتم مشاركة رمز الجهاز مع خادم SimpleX Chat ، ولكن ليس عدد جهات الاتصال أو الرسائل لديكم.
No comment provided by engineer.
-
- **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).
+
+ **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.
** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامكم للتطبيق).
No comment provided by engineer.
@@ -217,8 +212,8 @@
** يرجى ملاحظة **: لن تتمكنوا من استعادة أو تغيير عبارة المرور إذا فقدتموها.
No comment provided by engineer.
-
- **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.
+
+ **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.
** موصى به **: يتم إرسال رمز الجهاز والإشعارات إلى خادم إشعارات SimpleX Chat ، ولكن ليس محتوى الرسالة أو حجمها أو مصدرها.
No comment provided by engineer.
@@ -1048,8 +1043,8 @@
Direct messages
chat feature
-
- Direct messages between members are prohibited in this group.
+
+ Direct messages between members are prohibited.
No comment provided by engineer.
@@ -1064,8 +1059,8 @@
Disappearing messages are prohibited in this chat.
No comment provided by engineer.
-
- Disappearing messages are prohibited in this group.
+
+ Disappearing messages are prohibited.
No comment provided by engineer.
@@ -1428,16 +1423,16 @@
Group members can irreversibly delete sent messages.
No comment provided by engineer.
-
- Group members can send direct messages.
+
+ Members can send direct messages.
No comment provided by engineer.
-
- Group members can send disappearing messages.
+
+ Members can send disappearing messages.
No comment provided by engineer.
-
- Group members can send voice messages.
+
+ Members can send voice messages.
No comment provided by engineer.
@@ -1528,8 +1523,8 @@
Image will be received when your contact is online, please wait or check later!
No comment provided by engineer.
-
- Immune to spam and abuse
+
+ Immune to spam
No comment provided by engineer.
@@ -1625,8 +1620,8 @@
Irreversible message deletion is prohibited in this chat.
No comment provided by engineer.
-
- Irreversible message deletion is prohibited in this group.
+
+ Irreversible message deletion is prohibited.
No comment provided by engineer.
@@ -1926,8 +1921,8 @@ We will be adding server redundancy to prevent lost messages.
Onion hosts will not be used.
No comment provided by engineer.
-
- Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.
+
+ Only client devices store user profiles, contacts, groups, and messages.
No comment provided by engineer.
@@ -1978,8 +1973,8 @@ We will be adding server redundancy to prevent lost messages.
Open user profiles
authentication reason
-
- Open-source protocol and code – anybody can run the servers.
+
+ Anybody can host servers.
No comment provided by engineer.
@@ -2010,8 +2005,8 @@ We will be adding server redundancy to prevent lost messages.
Paste the link you received into the box below to connect with your contact.
No comment provided by engineer.
-
- People can connect to you only via the links you share.
+
+ You decide who can connect.
No comment provided by engineer.
@@ -2590,8 +2585,8 @@ We will be adding server redundancy to prevent lost messages.
Thanks to the users – contribute via Weblate!
No comment provided by engineer.
-
- The 1st platform without any user identifiers – private by design.
+
+ No user identifiers.
No comment provided by engineer.
@@ -2622,8 +2617,8 @@ We will be adding server redundancy to prevent lost messages.
The microphone does not work when the app is in the background.
No comment provided by engineer.
-
- The next generation of private messaging
+
+ The future of messaging
No comment provided by engineer.
@@ -2686,8 +2681,8 @@ We will be adding server redundancy to prevent lost messages.
To prevent the call interruption, enable Do Not Disturb mode.
No comment provided by engineer.
-
- To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.
+
+ To protect your privacy, SimpleX uses separate IDs for each of your contacts.
No comment provided by engineer.
@@ -2860,8 +2855,8 @@ To connect, please ask your contact to create another connection link and check
Voice messages are prohibited in this chat.
No comment provided by engineer.
-
- Voice messages are prohibited in this group.
+
+ Voice messages are prohibited.
No comment provided by engineer.
@@ -2972,10 +2967,6 @@ To connect, please ask your contact to create another connection link and check
You can use markdown to format messages:
No comment provided by engineer.
-
- You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.
- No comment provided by engineer.
-
You could not be verified; please try again.
No comment provided by engineer.
@@ -3752,8 +3743,8 @@ SimpleX servers cannot see your profile.
%u messages skipped.
%u تم تخطي الرسائل.
-
- **Add contact**: to create a new invitation link, or connect via a link you received.
+
+ **Create 1-time link**: to create and share a new invitation link.
**إضافة جهة اتصال**: لإنشاء رابط دعوة جديد، أو الاتصال عبر الرابط الذي تلقيتوهم.
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
index 419f0ae864..4aa1f2213f 100644
--- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
+++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
@@ -12,21 +12,6 @@
No comment provided by engineer.
-
-
-
- No comment provided by engineer.
-
-
-
-
- No comment provided by engineer.
-
-
-
-
- No comment provided by engineer.
-
(
(
@@ -127,6 +112,14 @@
%@ е потвърдено
No comment provided by engineer.
+
+ %@ server
+ No comment provided by engineer.
+
+
+ %@ servers
+ No comment provided by engineer.
+
%@ uploaded
%@ качено
@@ -137,6 +130,10 @@
%@ иска да се свърже!
notification title
+
+ %1$@, %2$@
+ format for date separator in chat
+
%@, %@ and %lld members
%@, %@ и %lld членове
@@ -157,11 +154,31 @@
%d дни
time interval
+
+ %d file(s) are still being downloaded.
+ forward confirmation reason
+
+
+ %d file(s) failed to download.
+ forward confirmation reason
+
+
+ %d file(s) were deleted.
+ forward confirmation reason
+
+
+ %d file(s) were not downloaded.
+ forward confirmation reason
+
%d hours
%d часа
time interval
+
+ %d messages not forwarded
+ alert title
+
%d min
%d мин.
@@ -322,14 +339,9 @@
)
No comment provided by engineer.
-
- **Add contact**: to create a new invitation link, or connect via a link you received.
- **Добави контакт**: за създаване на нов линк или свързване чрез получен линк за връзка.
- No comment provided by engineer.
-
-
- **Add new contact**: to create your one-time QR Code or link for your contact.
- **Добави нов контакт**: за да създадете своя еднократен QR код или линк за вашия контакт.
+
+ **Create 1-time link**: to create and share a new invitation link.
+ **Добави контакт**: за създаване на нов линк.
No comment provided by engineer.
@@ -337,13 +349,13 @@
**Създай група**: за създаване на нова група.
No comment provided by engineer.
-
- **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.
+
+ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.
**По поверително**: проверявайте новите съобщения на всеки 20 минути. Токенът на устройството се споделя със сървъра за чат SimpleX, но не и колко контакти или съобщения имате.
No comment provided by engineer.
-
- **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).
+
+ **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.
**Най-поверително**: не използвайте сървъра за известия SimpleX Chat, периодично проверявайте съобщенията във фонов режим (зависи от това колко често използвате приложението).
No comment provided by engineer.
@@ -357,11 +369,15 @@
**Моля, обърнете внимание**: НЯМА да можете да възстановите или промените паролата, ако я загубите.
No comment provided by engineer.
-
- **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.
+
+ **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.
**Препоръчително**: токенът на устройството и известията се изпращат до сървъра за уведомяване на SimpleX Chat, но не и съдържанието, размерът на съобщението или от кого е.
No comment provided by engineer.
+
+ **Scan / Paste link**: to connect via a link you received.
+ No comment provided by engineer.
+
**Warning**: Instant push notifications require passphrase saved in Keychain.
**Внимание**: Незабавните push известия изискват парола, запазена в Keychain.
@@ -468,6 +484,14 @@
1 седмица
time interval
+
+ 1-time link
+ No comment provided by engineer.
+
+
+ 1-time link can be used *with one contact only* - share in person or via any messenger.
+ No comment provided by engineer.
+
5 minutes
5 минути
@@ -537,19 +561,13 @@
Откажи смяна на адрес?
No comment provided by engineer.
-
- About SimpleX
- За SimpleX
- No comment provided by engineer.
-
About SimpleX Chat
За SimpleX Chat
No comment provided by engineer.
-
- About SimpleX address
- Повече за SimpleX адреса
+
+ About operators
No comment provided by engineer.
@@ -563,6 +581,10 @@
accept incoming call via notification
swipe action
+
+ Accept conditions
+ No comment provided by engineer.
+
Accept connection request?
Приемане на заявка за връзка?
@@ -579,6 +601,10 @@
accept contact request via notification
swipe action
+
+ Accepted conditions
+ No comment provided by engineer.
+
Acknowledged
No comment provided by engineer.
@@ -596,14 +622,8 @@
Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти.
No comment provided by engineer.
-
- Add contact
- Добави контакт
- No comment provided by engineer.
-
-
- Add preset servers
- Добави предварително зададени сървъри
+
+ Add friends
No comment provided by engineer.
@@ -621,6 +641,10 @@
Добави сървъри чрез сканиране на QR кодове.
No comment provided by engineer.
+
+ Add team members
+ No comment provided by engineer.
+
Add to another device
Добави към друго устройство
@@ -631,6 +655,18 @@
Добави съобщение при посрещане
No comment provided by engineer.
+
+ Add your team members to the conversations.
+ No comment provided by engineer.
+
+
+ Added media & file servers
+ No comment provided by engineer.
+
+
+ Added message servers
+ No comment provided by engineer.
+
Additional accent
No comment provided by engineer.
@@ -653,6 +689,14 @@
Промяната на адреса ще бъде прекъсната. Ще се използва старият адрес за получаване.
No comment provided by engineer.
+
+ Address or 1-time link?
+ No comment provided by engineer.
+
+
+ Address settings
+ No comment provided by engineer.
+
Admins can block a member for all.
Администраторите могат да блокират член за всички.
@@ -687,8 +731,8 @@
Всички данни се изтриват при въвеждане.
No comment provided by engineer.
-
- All data is private to your device.
+
+ All data is kept private on your device.
No comment provided by engineer.
@@ -696,6 +740,10 @@
Всички членове на групата ще останат свързани.
No comment provided by engineer.
+
+ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.
+ No comment provided by engineer.
+
All messages will be deleted - this cannot be undone!
Всички съобщения ще бъдат изтрити - това не може да бъде отменено!
@@ -713,7 +761,7 @@
All profiles
- No comment provided by engineer.
+ profile dropdown
All your contacts will remain connected.
@@ -871,6 +919,11 @@
Отговор на повикване
No comment provided by engineer.
+
+ Anybody can host servers.
+ Протокол и код с отворен код – всеки може да оперира собствени сървъри.
+ No comment provided by engineer.
+
App build: %@
Компилация на приложението: %@
@@ -901,6 +954,10 @@
Кода за достъп до приложение се заменя с код за самоунищожение.
No comment provided by engineer.
+
+ App session
+ No comment provided by engineer.
+
App version
Версия на приложението
@@ -1003,6 +1060,10 @@
Автоматично приемане на изображения
No comment provided by engineer.
+
+ Auto-accept settings
+ alert title
+
Back
Назад
@@ -1027,11 +1088,19 @@
Лош хеш на съобщението
No comment provided by engineer.
+
+ Better calls
+ No comment provided by engineer.
+
Better groups
По-добри групи
No comment provided by engineer.
+
+ Better message dates.
+ No comment provided by engineer.
+
Better messages
По-добри съобщения
@@ -1041,6 +1110,18 @@
Better networking
No comment provided by engineer.
+
+ Better notifications
+ No comment provided by engineer.
+
+
+ Better security ✅
+ No comment provided by engineer.
+
+
+ Better user experience
+ No comment provided by engineer.
+
Black
No comment provided by engineer.
@@ -1118,6 +1199,14 @@
Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!
No comment provided by engineer.
+
+ Business address
+ No comment provided by engineer.
+
+
+ Business chats
+ No comment provided by engineer.
+
By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA).
Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА).
@@ -1167,7 +1256,8 @@
Cancel
Отказ
- No comment provided by engineer.
+ alert action
+ alert button
Cancel migration
@@ -1186,7 +1276,7 @@
Cannot receive file
Файлът не може да бъде получен
- No comment provided by engineer.
+ alert title
Capacity exceeded - recipient did not receive previously sent messages.
@@ -1202,6 +1292,10 @@
Промени
No comment provided by engineer.
+
+ Change chat profiles
+ authentication reason
+
Change database passphrase?
Промяна на паролата на базата данни?
@@ -1248,9 +1342,16 @@
authentication reason
set passcode view
-
- Chat archive
- Архив на чата
+
+ Chat
+ No comment provided by engineer.
+
+
+ Chat already exists
+ No comment provided by engineer.
+
+
+ Chat already exists!
No comment provided by engineer.
@@ -1310,19 +1411,44 @@
Чат настройки
No comment provided by engineer.
+
+ Chat preferences were changed.
+ alert message
+
+
+ Chat profile
+ Потребителски профил
+ No comment provided by engineer.
+
Chat theme
No comment provided by engineer.
+
+ Chat will be deleted for all members - this cannot be undone!
+ No comment provided by engineer.
+
+
+ Chat will be deleted for you - this cannot be undone!
+ No comment provided by engineer.
+
Chats
Чатове
No comment provided by engineer.
+
+ Check messages every 20 min.
+ No comment provided by engineer.
+
+
+ Check messages when allowed.
+ No comment provided by engineer.
+
Check server address and try again.
Проверете адреса на сървъра и опитайте отново.
- No comment provided by engineer.
+ alert title
Chinese and Spanish interface
@@ -1403,15 +1529,47 @@
Completed
No comment provided by engineer.
+
+ Conditions accepted on: %@.
+ No comment provided by engineer.
+
+
+ Conditions are accepted for the operator(s): **%@**.
+ No comment provided by engineer.
+
+
+ Conditions are already accepted for these operator(s): **%@**.
+ No comment provided by engineer.
+
+
+ Conditions of use
+ No comment provided by engineer.
+
+
+ Conditions will be accepted for enabled operators after 30 days.
+ No comment provided by engineer.
+
+
+ Conditions will be accepted for operator(s): **%@**.
+ No comment provided by engineer.
+
+
+ Conditions will be accepted for the operator(s): **%@**.
+ No comment provided by engineer.
+
+
+ Conditions will be accepted on: %@.
+ No comment provided by engineer.
+
+
+ Conditions will be automatically accepted for enabled operators on: %@.
+ No comment provided by engineer.
+
Configure ICE servers
Конфигурирай ICE сървъри
No comment provided by engineer.
-
- Configured %@ servers
- No comment provided by engineer.
-
Confirm
Потвърди
@@ -1592,6 +1750,10 @@ This is your own one-time link!
Заявката за връзка е изпратена!
No comment provided by engineer.
+
+ Connection security
+ No comment provided by engineer.
+
Connection terminated
Връзката е прекратена
@@ -1685,6 +1847,10 @@ This is your own one-time link!
Версия на ядрото: v%@
No comment provided by engineer.
+
+ Corner
+ No comment provided by engineer.
+
Correct name to %@?
Поправи име на %@?
@@ -1695,6 +1861,10 @@ This is your own one-time link!
Създай
No comment provided by engineer.
+
+ Create 1-time link
+ No comment provided by engineer.
+
Create SimpleX address
Създай SimpleX адрес
@@ -1705,11 +1875,6 @@ This is your own one-time link!
Създай група с автоматично генериран профилл.
No comment provided by engineer.
-
- Create an address to let people connect with you.
- Създайте адрес, за да позволите на хората да се свързват с вас.
- No comment provided by engineer.
-
Create file
Създай файл
@@ -1769,11 +1934,6 @@ This is your own one-time link!
Създаден на: %@
copied message info
-
- Created on %@
- Създаден на %@
- No comment provided by engineer.
-
Creating archive link
Създаване на архивен линк
@@ -1789,6 +1949,10 @@ This is your own one-time link!
Текущ kод за достъп
No comment provided by engineer.
+
+ Current conditions text couldn't be loaded, you can review conditions via this link:
+ No comment provided by engineer.
+
Current passphrase…
Текуща парола…
@@ -1808,6 +1972,10 @@ This is your own one-time link!
Персонализирано време
No comment provided by engineer.
+
+ Customizable message shape.
+ No comment provided by engineer.
+
Customize theme
No comment provided by engineer.
@@ -1936,7 +2104,8 @@ This is your own one-time link!
Delete
Изтрий
- chat item action
+ alert action
+ chat item action
swipe action
@@ -1973,14 +2142,8 @@ This is your own one-time link!
Изтрий и уведоми контакт
No comment provided by engineer.
-
- Delete archive
- Изтрий архив
- No comment provided by engineer.
-
-
- Delete chat archive?
- Изтриване на архива на чата?
+
+ Delete chat
No comment provided by engineer.
@@ -1993,6 +2156,10 @@ This is your own one-time link!
Изтриване на чат профила?
No comment provided by engineer.
+
+ Delete chat?
+ No comment provided by engineer.
+
Delete connection
Изтрий връзката
@@ -2097,6 +2264,10 @@ This is your own one-time link!
Изтрий старата база данни?
No comment provided by engineer.
+
+ Delete or moderate up to 200 messages.
+ No comment provided by engineer.
+
Delete pending connection?
Изтрий предстоящата връзка?
@@ -2143,6 +2314,10 @@ This is your own one-time link!
Deletion errors
No comment provided by engineer.
+
+ Delivered even when Apple drops them.
+ No comment provided by engineer.
+
Delivery
Доставка
@@ -2237,8 +2412,12 @@ This is your own one-time link!
Лични съобщения
chat feature
-
- Direct messages between members are prohibited in this group.
+
+ Direct messages between members are prohibited in this chat.
+ No comment provided by engineer.
+
+
+ Direct messages between members are prohibited.
Личните съобщения между членовете са забранени в тази група.
No comment provided by engineer.
@@ -2276,8 +2455,8 @@ This is your own one-time link!
Изчезващите съобщения са забранени в този чат.
No comment provided by engineer.
-
- Disappearing messages are prohibited in this group.
+
+ Disappearing messages are prohibited.
Изчезващите съобщения са забранени в тази група.
No comment provided by engineer.
@@ -2334,6 +2513,10 @@ This is your own one-time link!
Не изпращай история на нови членове.
No comment provided by engineer.
+
+ Do not use credentials with proxy.
+ No comment provided by engineer.
+
Don't create address
Не създавай адрес
@@ -2357,7 +2540,8 @@ This is your own one-time link!
Download
Изтегли
- chat item action
+ alert button
+ chat item action
Download errors
@@ -2373,6 +2557,10 @@ This is your own one-time link!
Свали файл
server test step
+
+ Download files
+ alert action
+
Downloaded
No comment provided by engineer.
@@ -2401,6 +2589,10 @@ This is your own one-time link!
Продължителност
No comment provided by engineer.
+
+ E2E encrypted notifications.
+ No comment provided by engineer.
+
Edit
Редактирай
@@ -2421,6 +2613,10 @@ This is your own one-time link!
Активиране (запазване на промените)
No comment provided by engineer.
+
+ Enable Flux
+ No comment provided by engineer.
+
Enable SimpleX Lock
Активирай SimpleX заключване
@@ -2625,26 +2821,33 @@ This is your own one-time link!
Грешка при отказване на промяна на адреса
No comment provided by engineer.
+
+ Error accepting conditions
+ alert title
+
Error accepting contact request
Грешка при приемане на заявка за контакт
No comment provided by engineer.
-
- Error accessing database file
- Грешка при достъпа до файла с базата данни
- No comment provided by engineer.
-
Error adding member(s)
Грешка при добавяне на член(ове)
No comment provided by engineer.
+
+ Error adding server
+ alert title
+
Error changing address
Грешка при промяна на адреса
No comment provided by engineer.
+
+ Error changing connection profile
+ No comment provided by engineer.
+
Error changing role
Грешка при промяна на ролята
@@ -2655,6 +2858,10 @@ This is your own one-time link!
Грешка при промяна на настройката
No comment provided by engineer.
+
+ Error changing to incognito!
+ No comment provided by engineer.
+
Error connecting to forwarding server %@. Please try later.
No comment provided by engineer.
@@ -2768,9 +2975,12 @@ This is your own one-time link!
Грешка при присъединяване към група
No comment provided by engineer.
-
- Error loading %@ servers
- Грешка при зареждане на %@ сървъри
+
+ Error loading servers
+ alert title
+
+
+ Error migrating settings
No comment provided by engineer.
@@ -2781,7 +2991,7 @@ This is your own one-time link!
Error receiving file
Грешка при получаване на файл
- No comment provided by engineer.
+ alert title
Error reconnecting server
@@ -2800,11 +3010,6 @@ This is your own one-time link!
Error resetting statistics
No comment provided by engineer.
-
- Error saving %@ servers
- Грешка при запазване на %@ сървъра
- No comment provided by engineer.
-
Error saving ICE servers
Грешка при запазване на ICE сървърите
@@ -2825,6 +3030,10 @@ This is your own one-time link!
Грешка при запазване на парола в Кeychain
No comment provided by engineer.
+
+ Error saving servers
+ alert title
+
Error saving settings
Грешка при запазване на настройките
@@ -2870,10 +3079,14 @@ This is your own one-time link!
Грешка при спиране на чата
No comment provided by engineer.
+
+ Error switching profile
+ No comment provided by engineer.
+
Error switching profile!
Грешка при смяна на профил!
- No comment provided by engineer.
+ alertTitle
Error synchronizing connection
@@ -2890,6 +3103,10 @@ This is your own one-time link!
Грешка при актуализиране на съобщението
No comment provided by engineer.
+
+ Error updating server
+ alert title
+
Error updating settings
Грешка при актуализиране на настройките
@@ -2918,8 +3135,7 @@ This is your own one-time link!
Error: %@
Грешка: %@
- file error text
- snd error text
+ alert message
Error: URL is invalid
@@ -2935,6 +3151,10 @@ This is your own one-time link!
Errors
No comment provided by engineer.
+
+ Errors in servers configuration.
+ servers error
+
Even when disabled in the conversation.
Дори когато е деактивиран в разговора.
@@ -3003,6 +3223,11 @@ This is your own one-time link!
File error
No comment provided by engineer.
+
+ File errors:
+%@
+ alert message
+
File not found - most likely file was deleted or cancelled.
file error text
@@ -3053,8 +3278,8 @@ This is your own one-time link!
Файлове и медия
chat feature
-
- Files and media are prohibited in this group.
+
+ Files and media are prohibited.
Файловете и медията са забранени в тази група.
No comment provided by engineer.
@@ -3123,21 +3348,53 @@ This is your own one-time link!
Поправката не се поддържа от члена на групата
No comment provided by engineer.
+
+ For chat profile %@:
+ servers error
+
For console
За конзолата
No comment provided by engineer.
+
+ For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server.
+ No comment provided by engineer.
+
+
+ For private routing
+ No comment provided by engineer.
+
+
+ For social media
+ No comment provided by engineer.
+
Forward
Препрати
chat item action
+
+ Forward %d message(s)?
+ alert title
+
Forward and save messages
Препращане и запазване на съобщения
No comment provided by engineer.
+
+ Forward messages
+ alert action
+
+
+ Forward messages without files?
+ alert message
+
+
+ Forward up to 20 messages at once.
+ No comment provided by engineer.
+
Forwarded
Препратено
@@ -3148,6 +3405,10 @@ This is your own one-time link!
Препратено от
No comment provided by engineer.
+
+ Forwarding %lld messages
+ No comment provided by engineer.
+
Forwarding server %@ failed to connect to destination server %@. Please try later.
No comment provided by engineer.
@@ -3190,11 +3451,6 @@ Error: %2$@
Пълно име (незадължително)
No comment provided by engineer.
-
- Full name:
- Пълно име:
- No comment provided by engineer.
-
Fully decentralized – visible only to members.
Напълно децентрализирана – видима е само за членовете.
@@ -3278,41 +3534,6 @@ Error: %2$@
Групови линкове
No comment provided by engineer.
-
- Group members can add message reactions.
- Членовете на групата могат да добавят реакции към съобщенията.
- No comment provided by engineer.
-
-
- Group members can irreversibly delete sent messages. (24 hours)
- Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа)
- No comment provided by engineer.
-
-
- Group members can send SimpleX links.
- Членовете на групата могат да изпращат SimpleX линкове.
- No comment provided by engineer.
-
-
- Group members can send direct messages.
- Членовете на групата могат да изпращат лични съобщения.
- No comment provided by engineer.
-
-
- Group members can send disappearing messages.
- Членовете на групата могат да изпращат изчезващи съобщения.
- No comment provided by engineer.
-
-
- Group members can send files and media.
- Членовете на групата могат да изпращат файлове и медия.
- No comment provided by engineer.
-
-
- Group members can send voice messages.
- Членовете на групата могат да изпращат гласови съобщения.
- No comment provided by engineer.
-
Group message:
Групово съобщение:
@@ -3408,9 +3629,12 @@ Error: %2$@
Как работи SimpleX
No comment provided by engineer.
-
- How it works
- Как работи
+
+ How it affects privacy
+ No comment provided by engineer.
+
+
+ How it helps privacy
No comment provided by engineer.
@@ -3438,6 +3662,10 @@ Error: %2$@
ICE сървъри (по един на ред)
No comment provided by engineer.
+
+ IP address
+ No comment provided by engineer.
+
If you can't meet in person, show QR code in a video call, or share the link.
Ако не можете да се срещнете лично, покажете QR код във видеоразговора или споделете линка.
@@ -3478,8 +3706,8 @@ Error: %2$@
Веднага
No comment provided by engineer.
-
- Immune to spam and abuse
+
+ Immune to spam
Защитен от спам и злоупотреби
No comment provided by engineer.
@@ -3512,6 +3740,11 @@ Error: %2$@
Импортиране на архив
No comment provided by engineer.
+
+ Improved delivery, reduced traffic usage.
+More improvements are coming soon!
+ No comment provided by engineer.
+
Improved message delivery
Подобрена доставка на съобщения
@@ -3612,6 +3845,11 @@ Error: %2$@
Инсталирайте [SimpleX Chat за терминал](https://github.com/simplex-chat/simplex-chat)
No comment provided by engineer.
+
+ Instant
+ Мигновено
+ No comment provided by engineer.
+
Instant push notifications will be hidden!
@@ -3619,11 +3857,6 @@ Error: %2$@
No comment provided by engineer.
-
- Instantly
- Мигновено
- No comment provided by engineer.
-
Interface
Интерфейс
@@ -3671,7 +3904,7 @@ Error: %2$@
Invalid server address!
Невалиден адрес на сървъра!
- No comment provided by engineer.
+ alert title
Invalid status
@@ -3693,6 +3926,10 @@ Error: %2$@
Покани членове
No comment provided by engineer.
+
+ Invite to chat
+ No comment provided by engineer.
+
Invite to group
Покани в групата
@@ -3708,8 +3945,8 @@ Error: %2$@
Необратимото изтриване на съобщения е забранено в този чат.
No comment provided by engineer.
-
- Irreversible message deletion is prohibited in this group.
+
+ Irreversible message deletion is prohibited.
Необратимото изтриване на съобщения е забранено в тази група.
No comment provided by engineer.
@@ -3798,7 +4035,7 @@ This is your link for group %@!
Keep
Запази
- No comment provided by engineer.
+ alert action
Keep conversation
@@ -3812,7 +4049,7 @@ This is your link for group %@!
Keep unused invitation?
Запази неизползваната покана за връзка?
- No comment provided by engineer.
+ alert title
Keep your connections
@@ -3849,6 +4086,14 @@ This is your link for group %@!
Напусни
swipe action
+
+ Leave chat
+ No comment provided by engineer.
+
+
+ Leave chat?
+ No comment provided by engineer.
+
Leave group
Напусни групата
@@ -3899,11 +4144,6 @@ This is your link for group %@!
Съобщения на живо
No comment provided by engineer.
-
- Local
- Локално
- No comment provided by engineer.
-
Local name
Локално име
@@ -3924,11 +4164,6 @@ This is your link for group %@!
Режим на заключване
No comment provided by engineer.
-
- Make a private connection
- Добави поверителна връзка
- No comment provided by engineer.
-
Make one message disappear
Накарайте едно съобщение да изчезне
@@ -3939,21 +4174,11 @@ This is your link for group %@!
Направи профила поверителен!
No comment provided by engineer.
-
- Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@).
- Уверете се, че %@ сървърните адреси са в правилен формат, разделени на редове и не се дублират (%@).
- No comment provided by engineer.
-
Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated.
Уверете се, че адресите на WebRTC ICE сървъра са в правилен формат, разделени на редове и не са дублирани.
No comment provided by engineer.
-
- Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*
- Много хора попитаха: *ако SimpleX няма потребителски идентификатори, как може да доставя съобщения?*
- No comment provided by engineer.
-
Mark deleted for everyone
Маркирай като изтрито за всички
@@ -3996,6 +4221,10 @@ This is your link for group %@!
Member inactive
item status text
+
+ Member role will be changed to "%@". All chat members will be notified.
+ No comment provided by engineer.
+
Member role will be changed to "%@". All group members will be notified.
Ролята на члена ще бъде променена на "%@". Всички членове на групата ще бъдат уведомени.
@@ -4006,11 +4235,50 @@ This is your link for group %@!
Ролята на члена ще бъде променена на "%@". Членът ще получи нова покана.
No comment provided by engineer.
+
+ Member will be removed from chat - this cannot be undone!
+ No comment provided by engineer.
+
Member will be removed from group - this cannot be undone!
Членът ще бъде премахнат от групата - това не може да бъде отменено!
No comment provided by engineer.
+
+ Members can add message reactions.
+ Членовете на групата могат да добавят реакции към съобщенията.
+ No comment provided by engineer.
+
+
+ Members can irreversibly delete sent messages. (24 hours)
+ Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа)
+ No comment provided by engineer.
+
+
+ Members can send SimpleX links.
+ Членовете на групата могат да изпращат SimpleX линкове.
+ No comment provided by engineer.
+
+
+ Members can send direct messages.
+ Членовете на групата могат да изпращат лични съобщения.
+ No comment provided by engineer.
+
+
+ Members can send disappearing messages.
+ Членовете на групата могат да изпращат изчезващи съобщения.
+ No comment provided by engineer.
+
+
+ Members can send files and media.
+ Членовете на групата могат да изпращат файлове и медия.
+ No comment provided by engineer.
+
+
+ Members can send voice messages.
+ Членовете на групата могат да изпращат гласови съобщения.
+ No comment provided by engineer.
+
-
- Message reactions are prohibited in this group.
+
+ Message reactions are prohibited.
Реакциите на съобщения са забранени в тази група.
No comment provided by engineer.
@@ -4069,6 +4337,10 @@ This is your link for group %@!
Message servers
No comment provided by engineer.
+
+ Message shape
+ No comment provided by engineer.
+
Message source remains private.
Източникът на съобщението остава скрит.
@@ -4115,6 +4387,10 @@ This is your link for group %@!
Messages sent
No comment provided by engineer.
+
+ Messages were deleted after you selected them.
+ alert message
+
Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.
Съобщенията, файловете и разговорите са защитени чрез **криптиране от край до край** с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом.
@@ -4180,9 +4456,9 @@ This is your link for group %@!
Миграцията е завършена
No comment provided by engineer.
-
- Migrations: %@
- Миграции: %@
+
+ Migrations:
+ Миграции:
No comment provided by engineer.
@@ -4210,6 +4486,10 @@ This is your link for group %@!
По-надеждна мрежова връзка.
No comment provided by engineer.
+
+ More reliable notifications
+ No comment provided by engineer.
+
Most likely this connection is deleted.
Най-вероятно тази връзка е изтрита.
@@ -4245,6 +4525,10 @@ This is your link for group %@!
Мрежова връзка
No comment provided by engineer.
+
+ Network decentralization
+ No comment provided by engineer.
+
Network issues - message expired after many attempts to send it.
snd error text
@@ -4254,6 +4538,10 @@ This is your link for group %@!
Управление на мрежата
No comment provided by engineer.
+
+ Network operator
+ No comment provided by engineer.
+
Network settings
Мрежови настройки
@@ -4269,6 +4557,14 @@ This is your link for group %@!
Нов kод за достъп
No comment provided by engineer.
+
+ New SOCKS credentials will be used every time you start the app.
+ No comment provided by engineer.
+
+
+ New SOCKS credentials will be used for each server.
+ No comment provided by engineer.
+
New chat
Нов чат
@@ -4288,11 +4584,6 @@ This is your link for group %@!
Нов контакт:
notification
-
- New database archive
- Нов архив на база данни
- No comment provided by engineer.
-
New desktop app!
Ново настолно приложение!
@@ -4303,6 +4594,10 @@ This is your link for group %@!
Ново име
No comment provided by engineer.
+
+ New events
+ notification
+
New in %@
Ново в %@
@@ -4327,6 +4622,10 @@ This is your link for group %@!
Нова парола…
No comment provided by engineer.
+
+ New server
+ No comment provided by engineer.
+
No
Не
@@ -4380,21 +4679,63 @@ This is your link for group %@!
No info, try to reload
No comment provided by engineer.
+
+ No media & file servers.
+ servers error
+
+
+ No message servers.
+ servers error
+
No network connection
Няма мрежова връзка
No comment provided by engineer.
+
+ No permission to record speech
+ No comment provided by engineer.
+
+
+ No permission to record video
+ No comment provided by engineer.
+
No permission to record voice message
Няма разрешение за запис на гласово съобщение
No comment provided by engineer.
+
+ No push server
+ Локално
+ No comment provided by engineer.
+
No received or sent files
Няма получени или изпратени файлове
No comment provided by engineer.
+
+ No servers for private message routing.
+ servers error
+
+
+ No servers to receive files.
+ servers error
+
+
+ No servers to receive messages.
+ servers error
+
+
+ No servers to send files.
+ servers error
+
+
+ No user identifiers.
+ Първата платформа без никакви потребителски идентификатори – поверителна по дизайн.
+ No comment provided by engineer.
+
Not compatible!
Несъвместим!
@@ -4404,6 +4745,10 @@ This is your link for group %@!
Nothing selected
No comment provided by engineer.
+
+ Nothing to forward!
+ alert title
+
Notifications
Известия
@@ -4414,6 +4759,10 @@ This is your link for group %@!
Известията са деактивирани!
No comment provided by engineer.
+
+ Notifications privacy
+ No comment provided by engineer.
+
Now admins can:
- delete members' messages.
@@ -4436,18 +4785,13 @@ This is your link for group %@!
Ok
Ок
- No comment provided by engineer.
+ alert button
Old database
Стара база данни
No comment provided by engineer.
-
- Old database archive
- Стар архив на база данни
- No comment provided by engineer.
-
One-time invitation link
Линк за еднократна покана
@@ -4472,8 +4816,12 @@ Requires compatible VPN.
Няма се използват Onion хостове.
No comment provided by engineer.
-
- Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.
+
+ Only chat owners can change preferences.
+ No comment provided by engineer.
+
+
+ Only client devices store user profiles, contacts, groups, and messages.
Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения, изпратени с **двуслойно криптиране от край до край**.
No comment provided by engineer.
@@ -4556,6 +4904,10 @@ Requires compatible VPN.
Отвори настройки
No comment provided by engineer.
+
+ Open changes
+ No comment provided by engineer.
+
Open chat
Отвори чат
@@ -4566,6 +4918,10 @@ Requires compatible VPN.
Отвори конзолата
authentication reason
+
+ Open conditions
+ No comment provided by engineer.
+
Open group
Отвори група
@@ -4576,25 +4932,23 @@ Requires compatible VPN.
Отвори миграцията към друго устройство
authentication reason
-
- Open server settings
- No comment provided by engineer.
-
-
- Open user profiles
- Отвори потребителските профили
- authentication reason
-
-
- Open-source protocol and code – anybody can run the servers.
- Протокол и код с отворен код – всеки може да оперира собствени сървъри.
- No comment provided by engineer.
-
Opening app…
Приложението се отваря…
No comment provided by engineer.
+
+ Operator
+ No comment provided by engineer.
+
+
+ Operator server
+ alert title
+
+
+ Or import archive file
+ No comment provided by engineer.
+
Or paste archive link
Или постави архивен линк
@@ -4615,14 +4969,19 @@ Requires compatible VPN.
Или покажи този код
No comment provided by engineer.
+
+ Or to share privately
+ No comment provided by engineer.
+
Other
Други
No comment provided by engineer.
-
- Other %@ servers
- No comment provided by engineer.
+
+ Other file errors:
+%@
+ alert message
PING count
@@ -4659,6 +5018,10 @@ Requires compatible VPN.
Кодът за достъп е зададен!
No comment provided by engineer.
+
+ Password
+ No comment provided by engineer.
+
Password to show
Парола за показване
@@ -4693,13 +5056,8 @@ Requires compatible VPN.
Pending
No comment provided by engineer.
-
- People can connect to you only via the links you share.
- Хората могат да се свържат с вас само чрез ликовете, които споделяте.
- No comment provided by engineer.
-
-
- Periodically
+
+ Periodic
Периодично
No comment provided by engineer.
@@ -4803,6 +5161,10 @@ Error: %@
Полски интерфейс
No comment provided by engineer.
+
+ Port
+ No comment provided by engineer.
+
Possibly, certificate fingerprint in server address is incorrect
Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен
@@ -4813,16 +5175,15 @@ Error: %@
Запазете последната чернова на съобщението с прикачени файлове.
No comment provided by engineer.
-
- Preset server
- Предварително зададен сървър
- No comment provided by engineer.
-
Preset server address
Предварително зададен адрес на сървъра
No comment provided by engineer.
+
+ Preset servers
+ No comment provided by engineer.
+
Preview
Визуализация
@@ -4837,6 +5198,10 @@ Error: %@
Поверителност и сигурност
No comment provided by engineer.
+
+ Privacy for your customers.
+ No comment provided by engineer.
+
Privacy redefined
Поверителността преосмислена
@@ -4883,16 +5248,6 @@ Error: %@
Профилни изображения
No comment provided by engineer.
-
- Profile name
- Име на профила
- No comment provided by engineer.
-
-
- Profile name:
- Име на профила:
- No comment provided by engineer.
-
Profile password
Профилна парола
@@ -4905,7 +5260,7 @@ Error: %@
Profile update will be sent to your contacts.
Актуализацията на профила ще бъде изпратена до вашите контакти.
- No comment provided by engineer.
+ alert message
Prohibit audio/video calls.
@@ -4989,6 +5344,10 @@ Enable in *Network & servers* settings.
Proxied servers
No comment provided by engineer.
+
+ Proxy requires password
+ No comment provided by engineer.
+
Push notifications
Push известия
@@ -5028,26 +5387,21 @@ Enable in *Network & servers* settings.
Прочетете още
No comment provided by engineer.
-
- Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).
- Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).
- No comment provided by engineer.
-
Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).
Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).
No comment provided by engineer.
+
+ Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).
+ Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).
+ No comment provided by engineer.
+
Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).
Прочетете повече в [Ръководство на потребителя](https://simplex.chat/docs/guide/readme.html#connect-to-friends).
No comment provided by engineer.
-
- Read more in our GitHub repository.
- Прочетете повече в нашето хранилище в GitHub.
- No comment provided by engineer.
-
Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).
Прочетете повече в нашето [GitHub хранилище](https://github.com/simplex-chat/simplex-chat#readme).
@@ -5200,6 +5554,10 @@ Enable in *Network & servers* settings.
Премахване
No comment provided by engineer.
+
+ Remove archive?
+ No comment provided by engineer.
+
Remove image
No comment provided by engineer.
@@ -5344,6 +5702,14 @@ Enable in *Network & servers* settings.
Покажи
chat item action
+
+ Review conditions
+ No comment provided by engineer.
+
+
+ Review later
+ No comment provided by engineer.
+
Revoke
Отзови
@@ -5373,6 +5739,10 @@ Enable in *Network & servers* settings.
SMP server
No comment provided by engineer.
+
+ SOCKS proxy
+ No comment provided by engineer.
+
Safely receive files
No comment provided by engineer.
@@ -5385,17 +5755,18 @@ Enable in *Network & servers* settings.
Save
Запази
- chat item action
+ alert button
+ chat item action
Save (and notify contacts)
Запази (и уведоми контактите)
- No comment provided by engineer.
+ alert button
Save and notify contact
Запази и уведоми контакта
- No comment provided by engineer.
+ alert button
Save and notify group members
@@ -5411,16 +5782,6 @@ Enable in *Network & servers* settings.
Запази и актуализирай профила на групата
No comment provided by engineer.
-
- Save archive
- Запази архив
- No comment provided by engineer.
-
-
- Save auto-accept settings
- Запази настройките за автоматично приемане
- No comment provided by engineer.
-
Save group profile
Запази профила на групата
@@ -5439,7 +5800,7 @@ Enable in *Network & servers* settings.
Save preferences?
Запази настройките?
- No comment provided by engineer.
+ alert title
Save profile password
@@ -5454,18 +5815,17 @@ Enable in *Network & servers* settings.
Save servers?
Запази сървърите?
- No comment provided by engineer.
-
-
- Save settings?
- Запази настройките?
- No comment provided by engineer.
+ alert title
Save welcome message?
Запази съобщението при посрещане?
No comment provided by engineer.
+
+ Save your profile?
+ alert title
+
Saved
Запазено
@@ -5486,6 +5846,10 @@ Enable in *Network & servers* settings.
Запазено съобщение
message info title
+
+ Saving %lld messages
+ No comment provided by engineer.
+
Scale
No comment provided by engineer.
@@ -5562,6 +5926,10 @@ Enable in *Network & servers* settings.
Избери
chat item action
+
+ Select chat profile
+ No comment provided by engineer.
+
Selected %lld
No comment provided by engineer.
@@ -5646,11 +6014,6 @@ Enable in *Network & servers* settings.
Изпращай известия
No comment provided by engineer.
-
- Send notifications:
- Изпратени известия:
- No comment provided by engineer.
-
Send questions and ideas
Изпращайте въпроси и идеи
@@ -5674,7 +6037,7 @@ Enable in *Network & servers* settings.
Sender cancelled file transfer.
Подателят отмени прехвърлянето на файла.
- No comment provided by engineer.
+ alert message
Sender may have deleted the connection request.
@@ -5766,6 +6129,14 @@ Enable in *Network & servers* settings.
Sent via proxy
No comment provided by engineer.
+
+ Server
+ No comment provided by engineer.
+
+
+ Server added to operator %@.
+ alert message
+
Server address
No comment provided by engineer.
@@ -5778,6 +6149,18 @@ Enable in *Network & servers* settings.
Server address is incompatible with network settings: %@.
No comment provided by engineer.
+
+ Server operator changed.
+ alert title
+
+
+ Server operators
+ No comment provided by engineer.
+
+
+ Server protocol changed.
+ alert title
+
Server requires authorization to create queues, check password
Сървърът изисква оторизация за създаване на опашки, проверете паролата
@@ -5877,6 +6260,10 @@ Enable in *Network & servers* settings.
Настройки
No comment provided by engineer.
+
+ Settings were changed.
+ alert message
+
Shape profile images
Променете формата на профилните изображения
@@ -5885,22 +6272,35 @@ Enable in *Network & servers* settings.
Share
Сподели
- chat item action
+ alert action
+ chat item action
Share 1-time link
Сподели еднократен линк
No comment provided by engineer.
+
+ Share 1-time link with a friend
+ No comment provided by engineer.
+
+
+ Share SimpleX address on social media.
+ No comment provided by engineer.
+
Share address
Сподели адрес
No comment provided by engineer.
+
+ Share address publicly
+ No comment provided by engineer.
+
Share address with contacts?
Сподели адреса с контактите?
- No comment provided by engineer.
+ alert title
Share from other apps.
@@ -5911,6 +6311,10 @@ Enable in *Network & servers* settings.
Сподели линк
No comment provided by engineer.
+
+ Share profile
+ No comment provided by engineer.
+
Share this 1-time invite link
Сподели този еднократен линк за връзка
@@ -5976,6 +6380,10 @@ Enable in *Network & servers* settings.
SimpleX Адрес
No comment provided by engineer.
+
+ SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.
+ No comment provided by engineer.
+
SimpleX Chat security was audited by Trail of Bits.
Сигурността на SimpleX Chat беше одитирана от Trail of Bits.
@@ -6006,6 +6414,14 @@ Enable in *Network & servers* settings.
SimpleX адрес
No comment provided by engineer.
+
+ SimpleX address and 1-time links are safe to share via any messenger.
+ No comment provided by engineer.
+
+
+ SimpleX address or 1-time link?
+ No comment provided by engineer.
+
SimpleX contact address
SimpleX адрес за контакт
@@ -6026,8 +6442,8 @@ Enable in *Network & servers* settings.
SimpleX линкове
chat feature
-
- SimpleX links are prohibited in this group.
+
+ SimpleX links are prohibited.
SimpleX линкове са забранени в тази група.
No comment provided by engineer.
@@ -6041,6 +6457,10 @@ Enable in *Network & servers* settings.
Еднократна покана за SimpleX
simplex link type
+
+ SimpleX protocols reviewed by Trail of Bits.
+ No comment provided by engineer.
+
Simplified incognito mode
Опростен режим инкогнито
@@ -6069,6 +6489,10 @@ Enable in *Network & servers* settings.
Soft
blur media
+
+ Some app settings were not migrated.
+ No comment provided by engineer.
+
Some file(s) were not exported:
No comment provided by engineer.
@@ -6082,6 +6506,11 @@ Enable in *Network & servers* settings.
Some non-fatal errors occurred during import:
No comment provided by engineer.
+
+ Some servers failed the test:
+%@
+ alert message
+
Somebody
Някой
@@ -6130,11 +6559,6 @@ Enable in *Network & servers* settings.
Спри чата
No comment provided by engineer.
-
- Stop chat to enable database actions
- Спрете чата, за да активирате действията с базата данни
- No comment provided by engineer.
-
Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped.
Спрете чата, за да експортирате, импортирате или изтриете чат базата данни. Няма да можете да получавате и изпращате съобщения, докато чатът е спрян.
@@ -6163,12 +6587,12 @@ Enable in *Network & servers* settings.
Stop sharing
Спри споделянето
- No comment provided by engineer.
+ alert action
Stop sharing address?
Спри споделянето на адреса?
- No comment provided by engineer.
+ alert title
Stopping chat
@@ -6201,6 +6625,14 @@ Enable in *Network & servers* settings.
Подкрепете SimpleX Chat
No comment provided by engineer.
+
+ Switch audio and video during the call.
+ No comment provided by engineer.
+
+
+ Switch chat profile for 1-time invitations.
+ No comment provided by engineer.
+
System
Системен
@@ -6235,11 +6667,19 @@ Enable in *Network & servers* settings.
TCP_KEEPINTVL
No comment provided by engineer.
+
+ Tail
+ No comment provided by engineer.
+
Take picture
Направи снимка
No comment provided by engineer.
+
Tap button
Докосни бутона
@@ -6297,7 +6737,7 @@ Enable in *Network & servers* settings.
Tests failed!
Тестовете са неуспешни!
- No comment provided by engineer.
+ alert title
Thank you for installing SimpleX Chat!
@@ -6314,11 +6754,6 @@ Enable in *Network & servers* settings.
Благодарение на потребителите – допринесете през Weblate!
No comment provided by engineer.
-
- The 1st platform without any user identifiers – private by design.
- Първата платформа без никакви потребителски идентификатори – поверителна по дизайн.
- No comment provided by engineer.
-
The ID of the next message is incorrect (less or equal to the previous).
It can happen because of some bug or when the connection is compromised.
@@ -6331,6 +6766,10 @@ It can happen because of some bug or when the connection is compromised.Приложението може да ви уведоми, когато получите съобщения или заявки за контакт - моля, отворете настройките, за да активирате.
No comment provided by engineer.
+
+ The app protects your privacy by using different operators in each conversation.
+ No comment provided by engineer.
+
The app will ask to confirm downloads from unknown file servers (except .onion).
No comment provided by engineer.
@@ -6345,6 +6784,10 @@ It can happen because of some bug or when the connection is compromised.QR кодът, който сканирахте, не е SimpleX линк за връзка.
No comment provided by engineer.
+
+ The connection reached the limit of undelivered messages, your contact may be offline.
+ No comment provided by engineer.
+
The connection you accepted will be cancelled!
Връзката, която приехте, ще бъде отказана!
@@ -6365,6 +6808,11 @@ It can happen because of some bug or when the connection is compromised.Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване!
No comment provided by engineer.
+
+ The future of messaging
+ Ново поколение поверителни съобщения
+ No comment provided by engineer.
+
The hash of the previous message is different.
Хешът на предишното съобщение е различен.
@@ -6388,11 +6836,6 @@ It can happen because of some bug or when the connection is compromised.The messages will be marked as moderated for all members.
No comment provided by engineer.
-
- The next generation of private messaging
- Ново поколение поверителни съобщения
- No comment provided by engineer.
-
The old database was not removed during the migration, it can be deleted.
Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита.
@@ -6403,6 +6846,18 @@ It can happen because of some bug or when the connection is compromised.Профилът се споделя само с вашите контакти.
No comment provided by engineer.
+
+ The same conditions will apply to operator **%@**.
+ No comment provided by engineer.
+
+
+ The same conditions will apply to operator(s): **%@**.
+ No comment provided by engineer.
+
+
+ The second preset operator in the app!
+ No comment provided by engineer.
+
The second tick we missed! ✅
Втората отметка, която пропуснахме! ✅
@@ -6418,15 +6873,27 @@ It can happen because of some bug or when the connection is compromised.Сървърите за нови връзки на текущия ви чат профил **%@**.
No comment provided by engineer.
+
+ The servers for new files of your current chat profile **%@**.
+ No comment provided by engineer.
+
The text you pasted is not a SimpleX link.
Текстът, който поставихте, не е SimpleX линк за връзка.
No comment provided by engineer.
+
+ The uploaded database archive will be permanently removed from the servers.
+ No comment provided by engineer.
+
Themes
No comment provided by engineer.
+
+ These conditions will also apply for: **%@**.
+ No comment provided by engineer.
+
These settings are for your current profile **%@**.
Тези настройки са за текущия ви профил **%@**.
@@ -6525,9 +6992,8 @@ It can happen because of some bug or when the connection is compromised.За да направите нова връзка
No comment provided by engineer.
-
- To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.
- За да се защити поверителността, вместо потребителски идентификатори, използвани от всички други платформи, SimpleX има идентификатори за опашки от съобщения, отделни за всеки от вашите контакти.
+
+ To protect against your link being replaced, you can compare contact security codes.
No comment provided by engineer.
@@ -6546,6 +7012,23 @@ You will be prompted to complete authentication before this feature is enabled.<
Ще бъдете подканени да извършите идентификация, преди тази функция да бъде активирана.
No comment provided by engineer.
+
+ To protect your privacy, SimpleX uses separate IDs for each of your contacts.
+ За да се защити поверителността, вместо потребителски идентификатори, използвани от всички други платформи, SimpleX има идентификатори за опашки от съобщения, отделни за всеки от вашите контакти.
+ No comment provided by engineer.
+
+
+ To receive
+ No comment provided by engineer.
+
+
+ To record speech please grant permission to use Microphone.
+ No comment provided by engineer.
+
+
+ To record video please grant permission to use Camera.
+ No comment provided by engineer.
+
To record voice message please grant permission to use Microphone.
За да запишете гласово съобщение, моля, дайте разрешение за използване на микрофон.
@@ -6556,11 +7039,19 @@ You will be prompted to complete authentication before this feature is enabled.<
За да разкриете своя скрит профил, въведете пълна парола в полето за търсене на страницата **Вашите чат профили**.
No comment provided by engineer.
+
+ To send
+ No comment provided by engineer.
+
To support instant push notifications the chat database has to be migrated.
За поддръжка на незабавни push известия, базата данни за чат трябва да бъде мигрирана.
No comment provided by engineer.
+
+ To use the servers of **%@**, accept conditions of use.
+ No comment provided by engineer.
+
To verify end-to-end encryption with your contact compare (or scan) the code on your devices.
За да проверите криптирането от край до край с вашия контакт, сравнете (или сканирайте) кода на вашите устройства.
@@ -6647,6 +7138,10 @@ You will be prompted to complete authentication before this feature is enabled.<
Отблокирай член?
No comment provided by engineer.
+
+ Undelivered messages
+ No comment provided by engineer.
+
Unexpected migration state
Неочаквано състояние на миграция
@@ -6694,7 +7189,7 @@ You will be prompted to complete authentication before this feature is enabled.<
Unknown servers!
- No comment provided by engineer.
+ alert title
Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions.
@@ -6799,11 +7294,19 @@ To connect, please ask your contact to create another connection link and check
Архивът се качва
No comment provided by engineer.
+
+ Use %@
+ No comment provided by engineer.
+
Use .onion hosts
Използвай .onion хостове
No comment provided by engineer.
+
+ Use SOCKS proxy
+ No comment provided by engineer.
+
Use SimpleX Chat servers?
Използвай сървърите на SimpleX Chat?
@@ -6819,6 +7322,14 @@ To connect, please ask your contact to create another connection link and check
Използвай текущия профил
No comment provided by engineer.
+
+ Use for files
+ No comment provided by engineer.
+
+
+ Use for messages
+ No comment provided by engineer.
+
Use for new connections
Използвай за нови връзки
@@ -6857,6 +7368,10 @@ To connect, please ask your contact to create another connection link and check
Използвай сървър
No comment provided by engineer.
+
+ Use servers
+ No comment provided by engineer.
+
Use the app while in the call.
Използвайте приложението по време на разговора.
@@ -6866,15 +7381,14 @@ To connect, please ask your contact to create another connection link and check
Use the app with one hand.
No comment provided by engineer.
-
- User profile
- Потребителски профил
- No comment provided by engineer.
-
User selection
No comment provided by engineer.
+
+ Username
+ No comment provided by engineer.
+
Using SimpleX Chat servers.
Използват се сървърите на SimpleX Chat.
@@ -6945,11 +7459,19 @@ To connect, please ask your contact to create another connection link and check
Видео и файлове до 1gb
No comment provided by engineer.
+
+ View conditions
+ No comment provided by engineer.
+
View security code
Виж кода за сигурност
No comment provided by engineer.
+
+ View updated conditions
+ No comment provided by engineer.
+
Visible history
Видима история
@@ -6965,8 +7487,8 @@ To connect, please ask your contact to create another connection link and check
Гласовите съобщения са забранени в този чат.
No comment provided by engineer.
-
- Voice messages are prohibited in this group.
+
+ Voice messages are prohibited.
Гласовите съобщения са забранени в тази група.
No comment provided by engineer.
@@ -7058,9 +7580,8 @@ To connect, please ask your contact to create another connection link and check
При свързване на аудио и видео разговори.
No comment provided by engineer.
-
- When people request to connect, you can accept or reject it.
- Когато хората искат да се свържат с вас, можете да ги приемете или отхвърлите.
+
+ When more than one operator is enabled, none of them has metadata to learn who communicates with whom.
No comment provided by engineer.
@@ -7104,7 +7625,7 @@ To connect, please ask your contact to create another connection link and check
Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.
- No comment provided by engineer.
+ alert message
Wrong database passphrase
@@ -7128,11 +7649,6 @@ To connect, please ask your contact to create another connection link and check
XFTP server
No comment provided by engineer.
-
- You
- Вие
- No comment provided by engineer.
-
You **must not** use the same database on two devices.
**Не трябва** да използвате една и съща база данни на две устройства.
@@ -7158,6 +7674,10 @@ To connect, please ask your contact to create another connection link and check
Вече сте вече свързани с %@.
No comment provided by engineer.
+
+ You are already connected with %@.
+ No comment provided by engineer.
+
You are already connecting to %@.
Вече се свързвате с %@.
@@ -7218,6 +7738,14 @@ Repeat join request?
You can change it in Appearance settings.
No comment provided by engineer.
+
+ You can configure operators in Network & servers settings.
+ No comment provided by engineer.
+
+
+ You can configure servers via settings.
+ No comment provided by engineer.
+
You can create it later
Можете да го създадете по-късно
@@ -7257,6 +7785,10 @@ Repeat join request?
You can send messages to %@ from Archived contacts.
No comment provided by engineer.
+
+ You can set connection name, to remember who the link was shared with.
+ No comment provided by engineer.
+
You can set lock screen notification preview via settings.
Можете да зададете визуализация на известията на заключен екран през настройките.
@@ -7272,11 +7804,6 @@ Repeat join request?
Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с **%@**.
No comment provided by engineer.
-
- You can share your address as a link or QR code - anybody can connect to you.
- Можете да споделите адреса си като линк или QR код - всеки може да се свърже с вас.
- No comment provided by engineer.
-
You can start chat via app Settings / Database or by restarting the app
Можете да започнете чат през Настройки на приложението / База данни или като рестартирате приложението
@@ -7299,23 +7826,23 @@ Repeat join request?
You can view invitation link again in connection details.
Можете да видите отново линкът за покана в подробностите за връзката.
- No comment provided by engineer.
+ alert message
You can't send messages!
Не може да изпращате съобщения!
No comment provided by engineer.
-
- You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.
- Вие контролирате през кой сървър(и) **да получавате** съобщенията, вашите контакти – сървърите, които използвате, за да им изпращате съобщения.
- No comment provided by engineer.
-
You could not be verified; please try again.
Не можахте да бъдете потвърдени; Моля, опитайте отново.
No comment provided by engineer.
+
+ You decide who can connect.
+ Хората могат да се свържат с вас само чрез ликовете, които споделяте.
+ No comment provided by engineer.
+
You have already requested connection via this address!
Вече сте заявили връзка през този адрес!
@@ -7415,6 +7942,10 @@ Repeat connection request?
Все още ще получавате обаждания и известия от заглушени профили, когато са активни.
No comment provided by engineer.
+
+ You will stop receiving messages from this chat. Chat history will be preserved.
+ No comment provided by engineer.
+
You will stop receiving messages from this group. Chat history will be preserved.
Ще спрете да получавате съобщения от тази група. Историята на чата ще бъде запазена.
@@ -7435,11 +7966,6 @@ Repeat connection request?
Използвате инкогнито профил за тази група - за да се предотврати споделянето на основния ви профил, поканите на контакти не са разрешени
No comment provided by engineer.
-
- Your %@ servers
- Вашите %@ сървъри
- No comment provided by engineer.
-
Your ICE servers
Вашите ICE сървъри
@@ -7455,11 +7981,6 @@ Repeat connection request?
Вашият SimpleX адрес
No comment provided by engineer.
-
- Your XFTP servers
- Вашите XFTP сървъри
- No comment provided by engineer.
-
Your calls
Вашите обаждания
@@ -7475,11 +7996,19 @@ Repeat connection request?
Вашата чат база данни не е криптирана - задайте парола, за да я криптирате.
No comment provided by engineer.
+
+ Your chat preferences
+ alert title
+
Your chat profiles
Вашите чат профили
No comment provided by engineer.
+
+ Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.
+ No comment provided by engineer.
+
Your contact sent a file that is larger than currently supported maximum size (%@).
Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%@).
@@ -7495,6 +8024,10 @@ Repeat connection request?
Вашите контакти ще останат свързани.
No comment provided by engineer.
+
+ Your credentials may be sent unencrypted.
+ No comment provided by engineer.
+
Your current chat database will be DELETED and REPLACED with the imported one.
Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната.
@@ -7525,13 +8058,15 @@ Repeat connection request?
Вашият профил **%@** ще бъде споделен.
No comment provided by engineer.
-
- Your profile is stored on your device and shared only with your contacts.
-SimpleX servers cannot see your profile.
- Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти.
-SimpleX сървърите не могат да видят вашия профил.
+
+ Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.
+ Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил.
No comment provided by engineer.
+
+ Your profile was changed. If you save it, the updated profile will be sent to all your contacts.
+ alert message
+
Your profile, contacts and delivered messages are stored on your device.
Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.
@@ -7542,16 +8077,15 @@ SimpleX сървърите не могат да видят вашия профи
Вашият автоматично генериран профил
No comment provided by engineer.
-
- Your server
- Вашият сървър
- No comment provided by engineer.
-
Your server address
Вашият адрес на сървъра
No comment provided by engineer.
+
+ Your servers
+ No comment provided by engineer.
+
Your settings
Вашите настройки
@@ -7592,6 +8126,10 @@ SimpleX сървърите не могат да видят вашия профи
обаждането прието
call status
+
+ accepted invitation
+ chat list item title
+
admin
админ
@@ -7778,7 +8316,7 @@ SimpleX сървърите не могат да видят вашия профи
connecting…
свързване…
- chat list item title
+ No comment provided by engineer.
connection established
@@ -7967,6 +8505,10 @@ SimpleX сървърите не могат да видят вашия профи
expired
No comment provided by engineer.
+
+ for better metadata privacy.
+ No comment provided by engineer.
+
forwarded
препратено
@@ -8258,6 +8800,10 @@ SimpleX сървърите не могат да видят вашия профи
ви острани
rcv group event chat item
+
+ requested to connect
+ chat list item title
+
saved
запазено
@@ -8574,6 +9120,33 @@ last received msg: %2$@