Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6581e27524 | |||
| 909edac64f | |||
| 30a24df9c0 | |||
| 7bcb514baf | |||
| bda84b08a1 | |||
| 2f0fe50f88 | |||
| 4f640c96d1 | |||
| 9b71702ac8 | |||
| 76aedb4a15 | |||
| a6f5ba541b | |||
| e47b16f3b4 | |||
| 494ef6e671 | |||
| bff2d7d3b6 | |||
| 2adfa0c18b | |||
| b5170684ad | |||
| 396fa7f988 | |||
| ea9ee987cf | |||
| 49d1b26bba | |||
| bab63d8f27 | |||
| 1083a0727a | |||
| 78b3b12ec1 | |||
| 61d7df8906 | |||
| 522f99aadd | |||
| 927a04d45f | |||
| 2b155db57d | |||
| f3cef7ce12 | |||
| 29b54ec5b2 | |||
| 313acefb19 | |||
| e5534c0402 | |||
| 4e37efdc4a | |||
| 58c92ed004 | |||
| 181f72fa1f | |||
| 4b9c618ae3 | |||
| 70a29512b7 | |||
| fcae5e9925 | |||
| 619985730e | |||
| 3d4a47cdae | |||
| d1ae3ba2d3 | |||
| feb4ecbb6b | |||
| e645dd99e7 | |||
| a17bfc52ce | |||
| 6843269cff | |||
| b605ebfd2a | |||
| feb687d3b8 | |||
| ff8e29c0eb | |||
| 1fbf21d395 | |||
| d42cab8e22 | |||
| 807f698cf2 | |||
| e45a96935c | |||
| a5061f3147 | |||
| 4d82209a3a | |||
| 60c37f0d1d | |||
| 15bac88ec9 | |||
| 8af54539f6 |
@@ -124,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
|
||||
@@ -138,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'
|
||||
@@ -334,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
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- blog/**
|
||||
- docs/**
|
||||
- .github/workflows/web.yml
|
||||
- PRIVACY.md
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -3,27 +3,49 @@ 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 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).
|
||||
|
||||
@@ -33,35 +55,41 @@ The cryptographic review of SimpleX protocols design was done in July 2024 by Tr
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -77,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.
|
||||
@@ -85,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 October 14, 2024
|
||||
Updated November 14, 2024
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -9,6 +9,18 @@ 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
|
||||
@@ -30,7 +42,8 @@ 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 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
|
||||
@@ -261,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,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()
|
||||
@@ -283,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 {
|
||||
|
||||
@@ -193,6 +193,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
// usage conditions
|
||||
@Published var conditions: ServerOperatorConditions = .empty
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
|
||||
@@ -500,18 +500,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))
|
||||
@@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
|
||||
throw r
|
||||
}
|
||||
|
||||
func getServerOperators() 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)))
|
||||
@@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
m.conditions = try getServerOperators()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
@@ -1602,7 +1650,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())
|
||||
@@ -1621,13 +1669,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,21 @@ enum UserPickerSheet: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -76,6 +88,21 @@ struct UserPickerSheetView: View {
|
||||
{ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +121,7 @@ struct ChatListView: View {
|
||||
@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 {
|
||||
@@ -276,12 +304,6 @@ struct ChatListView: View {
|
||||
.padding(.top, oneHandUI ? 8 : 0)
|
||||
.id("searchBar")
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
@@ -307,6 +329,20 @@ struct ChatListView: View {
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if !addressCreationCardShown {
|
||||
AddressCreationCard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: chatModel.chatId) { currentChatId in
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -491,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 {
|
||||
@@ -679,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 {
|
||||
|
||||
@@ -124,7 +124,7 @@ struct UserPicker: View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
if (u.unreadCount > 0) {
|
||||
unreadBadge(u).offset(x: 4, y: -4)
|
||||
UnreadBadge(userInfo: u).offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 6)
|
||||
@@ -169,15 +169,21 @@ struct UserPicker: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadBadge(_ u: UserInfo) -> some View {
|
||||
}
|
||||
|
||||
struct UnreadBadge: View {
|
||||
var userInfo: UserInfo
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
let size = dynamicSize(userFont).chatInfoSize
|
||||
return unreadCountText(u.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
unreadCountText(userInfo.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: size, minHeight: size)
|
||||
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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()
|
||||
HStack(alignment: .center) {
|
||||
Text("How to use it")
|
||||
VStack {
|
||||
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("You can create it in user picker."),
|
||||
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("SimpleX address")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.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()
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//
|
||||
// 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<Int64>()
|
||||
@State private var reviewConditionsNavLinkActive = false
|
||||
@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) {
|
||||
if !onboarding {
|
||||
Text("Choose operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
}
|
||||
|
||||
infoText()
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(serverOperators) { srvOperator in
|
||||
operatorCheckView(srvOperator)
|
||||
}
|
||||
Text("You can configure servers via settings.")
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
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(.callout)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
if !onboarding && !reviewForOperators.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
reviewLaterButton()
|
||||
(
|
||||
Text("Conditions will be accepted for enabled operators after 30 days.")
|
||||
+ Text(" ")
|
||||
+ Text("You can configure operators in Network & servers settings.")
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.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)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "info.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.onTapGesture {
|
||||
sheetItem = .showInfo
|
||||
}
|
||||
|
||||
Text("Select network operators to use.")
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
ZStack {
|
||||
Button {
|
||||
reviewConditionsNavLinkActive = true
|
||||
} label: {
|
||||
Text("Review conditions")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
|
||||
NavigationLink(isActive: $reviewConditionsNavLinkActive) {
|
||||
reviewConditionsDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.navigationTitle("Push notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
private func reviewConditionsDestinationView() -> some View {
|
||||
reviewConditionsView()
|
||||
.navigationTitle("Conditions of use")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
@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 following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
Text("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()
|
||||
acceptConditionsButton()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
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..<ops.count {
|
||||
var op = ops[i]
|
||||
op.enabled = selectedOperatorIds.contains(op.operatorId)
|
||||
ops[i] = op
|
||||
}
|
||||
let haveSMPStorage = ops.contains(where: { $0.enabled && $0.smpRoles.storage })
|
||||
let haveSMPProxy = ops.contains(where: { $0.enabled && $0.smpRoles.proxy })
|
||||
let haveXFTPStorage = ops.contains(where: { $0.enabled && $0.xftpRoles.storage })
|
||||
let haveXFTPProxy = ops.contains(where: { $0.enabled && $0.xftpRoles.proxy })
|
||||
if haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy {
|
||||
return ops
|
||||
} else if let firstEnabledIndex = ops.firstIndex(where: { $0.enabled }) {
|
||||
var op = ops[firstEnabledIndex]
|
||||
if !haveSMPStorage { op.smpRoles.storage = true }
|
||||
if !haveSMPProxy { op.smpRoles.proxy = true }
|
||||
if !haveXFTPStorage { op.xftpRoles.storage = true }
|
||||
if !haveXFTPProxy { op.xftpRoles.proxy = true }
|
||||
ops[firstEnabledIndex] = op
|
||||
return ops
|
||||
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooseServerOperatorsInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Network operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("When more than one network operator is enabled, the app will use the servers of different operators for each conversation.")
|
||||
Text("For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.")
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -78,6 +78,35 @@ struct CreateProfile: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateFirstProfile: View {
|
||||
@@ -86,40 +115,47 @@ 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 {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.bottom)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
||||
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 {
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.top)
|
||||
|
||||
Spacer()
|
||||
onboardingButtons()
|
||||
|
||||
createProfileButton()
|
||||
.padding(.bottom)
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
@@ -129,88 +165,85 @@ struct CreateFirstProfile: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
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)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.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 {
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
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) {
|
||||
@@ -37,8 +40,8 @@ struct HowItWorks: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
.padding(.bottom, 8)
|
||||
createFirstProfileButton()
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.lineLimit(10)
|
||||
@@ -46,10 +49,23 @@ struct HowItWorks: View {
|
||||
.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step4_SetNotificationsMode:
|
||||
SetNotificationsMode()
|
||||
.navigationTitle("Push notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .onboardingComplete: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -15,41 +15,39 @@ struct SetNotificationsMode: View {
|
||||
@State private var showAlert: NotificationAlert?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
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!")
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Send notifications:")
|
||||
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()
|
||||
|
||||
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())
|
||||
.padding(.bottom)
|
||||
}
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
|
||||
|
||||
@@ -13,12 +13,13 @@ 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) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
@@ -42,9 +43,9 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
Spacer()
|
||||
createFirstProfileButton()
|
||||
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
@@ -52,7 +53,6 @@ struct SimpleXInfo: View {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@@ -62,9 +62,8 @@ struct SimpleXInfo: View {
|
||||
Label("How it works", systemImage: "info.circle")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
@@ -83,7 +82,10 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHowItWorks) {
|
||||
HowItWorks(onboarding: onboarding)
|
||||
HowItWorks(
|
||||
onboarding: onboarding,
|
||||
createProfileNavLinkActive: $createProfileNavLinkActive
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
@@ -108,49 +110,31 @@ struct SimpleXInfo: View {
|
||||
.padding(.bottom, 20)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingActionButton: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private func createFirstProfileButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createProfileNavLinkActive = true
|
||||
} label: {
|
||||
Text("Create your profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
|
||||
var body: some View {
|
||||
if m.currentUser == nil {
|
||||
actionButton("Create your profile", onboarding: .step2_CreateProfile)
|
||||
} else {
|
||||
actionButton("Make a private connection", onboarding: .onboardingComplete)
|
||||
NavigationLink(isActive: $createProfileNavLinkActive) {
|
||||
createProfileDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private func createProfileDestinationView() -> some View {
|
||||
CreateFirstProfile()
|
||||
.navigationTitle("Create your profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,39 +473,39 @@ 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: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Better security ✅",
|
||||
description: "SimpleX protocols reviewed by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "video",
|
||||
title: "Better calls",
|
||||
description: "Switch audio and video during the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bolt",
|
||||
title: "Better notifications",
|
||||
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "Better user experience",
|
||||
description: nil,
|
||||
@@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
|
||||
("flag", "Delete or moderate up to 200 messages.")
|
||||
]
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.2 (beta.1)",
|
||||
post: URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html"),
|
||||
features: [
|
||||
.view(FeatureView(
|
||||
icon: nil,
|
||||
title: "Network decentralization",
|
||||
view: { NewOperatorsView() }
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bolt",
|
||||
title: "More reliable notifications",
|
||||
description: "Delivered even when Apple drops them."
|
||||
)),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
@@ -514,14 +549,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
|
||||
@@ -532,9 +621,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) {
|
||||
@@ -544,13 +635,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()
|
||||
}
|
||||
}
|
||||
@@ -568,20 +666,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)
|
||||
@@ -636,6 +738,6 @@ struct WhatsNewView: View {
|
||||
|
||||
struct NewFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WhatsNewView()
|
||||
WhatsNewView(updatedConditions: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,28 +19,78 @@ private enum NetworkAlert: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Message servers")
|
||||
ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
|
||||
if let serverOperator = userOperatorServers.operator {
|
||||
serverOperatorView(idx, serverOperator)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
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: {
|
||||
Text("Media & file servers")
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Your servers")
|
||||
|
||||
if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] {
|
||||
Spacer()
|
||||
unsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
@@ -55,6 +105,17 @@ struct NetworkAndServers: View {
|
||||
.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()
|
||||
@@ -74,6 +135,295 @@ struct NetworkAndServers: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.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<String> {
|
||||
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..<usvs.wrappedValue.count {
|
||||
if let updatedOperator = updatedOperators.first(where: { $0.operatorId == usvs.wrappedValue[i].operator?.operatorId }) {
|
||||
usvs.wrappedValue[i].operator?.conditionsAcceptance = updatedOperator.conditionsAcceptance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// NewServerView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 13.11.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct NewServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@State private var serverToEdit: UserServer = .empty
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
customServer()
|
||||
if testing {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
title: Text("Server test failed!"),
|
||||
message: Text(testFailure?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Possibly refactor - similar functions in ProtocolServerView
|
||||
private func customServer() -> 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([])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//
|
||||
// OperatorView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 28.10.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Link("\(serverOperator.info.website)", destination: URL(string: serverOperator.info.website)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConditionsTextView: View {
|
||||
@State private var conditionsData: (UsageConditions, String?, UsageConditions?)?
|
||||
@State private var failedToLoad: Bool = false
|
||||
|
||||
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 {
|
||||
conditionsData = try await getUsageConditions()
|
||||
} catch let error {
|
||||
logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))")
|
||||
failedToLoad = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Markdown & diff rendering
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if let (usageConditions, conditionsText, acceptedConditions) = conditionsData {
|
||||
if let conditionsText = conditionsText {
|
||||
ScrollView {
|
||||
Text(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
.padding()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@State private var usageConditionsNavLinkActive: Bool = false
|
||||
|
||||
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) {
|
||||
Group {
|
||||
viewHeader()
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
} else if !operatorsWithConditionsAccepted.isEmpty {
|
||||
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Group {
|
||||
viewHeader()
|
||||
Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
Text("Same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.")
|
||||
conditionsAppliedToOtherOperatorsText()
|
||||
usageConditionsNavLinkButton()
|
||||
|
||||
Spacer()
|
||||
|
||||
acceptConditionsButton()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Group {
|
||||
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 {
|
||||
ZStack {
|
||||
Button {
|
||||
usageConditionsNavLinkActive = true
|
||||
} label: {
|
||||
Text("View conditions")
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $usageConditionsNavLinkActive) {
|
||||
usageConditionsDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func usageConditionsDestinationView() -> some View {
|
||||
ConditionsTextView()
|
||||
.padding()
|
||||
.padding(.bottom)
|
||||
.navigationTitle("Conditions of use")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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<ServerCfg>) async -> ProtocolTestFailure? {
|
||||
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
|
||||
do {
|
||||
let r = try await testProtoServer(server: server.wrappedValue.server)
|
||||
switch r {
|
||||
@@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,238 +11,166 @@ import SimpleXChat
|
||||
|
||||
private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
|
||||
|
||||
struct ProtocolServersView: View {
|
||||
struct YourServersView: 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] = []
|
||||
@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 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)
|
||||
yourServersView()
|
||||
.opacity(testing ? 0.4 : 1)
|
||||
.overlay {
|
||||
if testing {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!testing)
|
||||
}
|
||||
|
||||
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 {
|
||||
@ViewBuilder private func yourServersView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
List {
|
||||
if !configuredServers.isEmpty {
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($configuredServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
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
|
||||
configuredServers.remove(atOffsets: indexSet)
|
||||
deleteSMPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Configured \(proto) servers")
|
||||
Text("Message 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 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 !otherServers.isEmpty {
|
||||
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($otherServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
otherServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
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
|
||||
otherServers.remove(atOffsets: indexSet)
|
||||
deleteXFTPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Other \(proto) servers")
|
||||
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 {
|
||||
Button("Add server") {
|
||||
showAddServer = true
|
||||
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 {
|
||||
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)
|
||||
TestServersButton(
|
||||
smpServers: $userServers[operatorIndex].smpServers,
|
||||
xftpServers: $userServers[operatorIndex].xftpServers,
|
||||
testing: $testing
|
||||
)
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") {
|
||||
otherServers.append(ServerCfg.empty)
|
||||
selectedServer = allServers.last?.id
|
||||
.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 }
|
||||
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<ServerCfg>) -> some View {
|
||||
let srv = server.wrappedValue
|
||||
return NavigationLink(tag: srv.id, selection: $selectedServer) {
|
||||
ProtocolServerView(
|
||||
serverProtocol: serverProtocol,
|
||||
server: server,
|
||||
serverToEdit: srv
|
||||
ScanProtocolServer(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -255,33 +183,114 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolServerViewLink: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
var duplicateHosts: Set<String>
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasAllPresets() -> Bool {
|
||||
presetServers.allSatisfy { hasPreset($0) }
|
||||
}
|
||||
|
||||
private func addAllPresets() {
|
||||
for srv in presetServers {
|
||||
if !hasPreset(srv) {
|
||||
configuredServers.append(srv)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasPreset(_ srv: ServerCfg) -> Bool {
|
||||
allServers.contains(where: { $0.server == srv.server })
|
||||
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() {
|
||||
@@ -292,68 +301,59 @@ struct ProtocolServersView: View {
|
||||
await MainActor.run {
|
||||
testing = false
|
||||
if !fs.isEmpty {
|
||||
alert = .testsFailed(failures: fs)
|
||||
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..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
configuredServers[i].tested = nil
|
||||
for i in 0..<smpServers.count {
|
||||
if smpServers[i].enabled {
|
||||
smpServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
otherServers[i].tested = nil
|
||||
|
||||
for i in 0..<xftpServers.count {
|
||||
if xftpServers[i].enabled {
|
||||
xftpServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $configuredServers[i]) {
|
||||
fs[serverHostname(configuredServers[i].server)] = f
|
||||
for i in 0..<smpServers.count {
|
||||
if smpServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $smpServers[i]) {
|
||||
fs[serverHostname(smpServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $otherServers[i]) {
|
||||
fs[serverHostname(otherServers[i].server)] = f
|
||||
|
||||
for i in 0..<xftpServers.count {
|
||||
if xftpServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $xftpServers[i]) {
|
||||
fs[serverHostname(xftpServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func saveServers() {
|
||||
Task {
|
||||
do {
|
||||
try await setUserProtoServers(serverProtocol, servers: allServers)
|
||||
await MainActor.run {
|
||||
currServers = allServers
|
||||
editMode?.wrappedValue = .inactive
|
||||
}
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
logger.error("saveServers setUserProtocolServers error: \(err)")
|
||||
await MainActor.run {
|
||||
alert = .error(
|
||||
title: "Error saving \(proto) servers",
|
||||
error: "Make sure \(proto) server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolServersView_Previews: PreviewProvider {
|
||||
struct YourServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
YourServersView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
operatorIndex: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import CodeScanner
|
||||
|
||||
struct ScanProtocolServer: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var servers: [ServerCfg]
|
||||
@State private var showAddressError = false
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(isPresented: $showAddressError) {
|
||||
Alert(
|
||||
title: Text("Invalid server address!"),
|
||||
message: Text("Check server address and try again.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
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([])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
||||
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
|
||||
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
@@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
|
||||
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
@@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [
|
||||
let hintDefaults = [
|
||||
DEFAULT_LA_NOTICE_SHOWN,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
|
||||
@@ -356,7 +359,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
WhatsNewView(viaSettings: true)
|
||||
WhatsNewView(viaSettings: true, updatedConditions: false)
|
||||
.modifier(ThemedBackground())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
|
||||
@@ -9,15 +9,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserAddressLearnMore: View {
|
||||
@State var showCreateAddressButton = false
|
||||
@State private var createAddressLinkActive = 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: 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).")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
if showCreateAddressButton {
|
||||
addressCreationButton()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct UserAddressView: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var shareViaProfile = false
|
||||
@State var autoCreate = false
|
||||
@State private var aas = AutoAcceptState()
|
||||
@State private var savedAAS = AutoAcceptState()
|
||||
@State private var ignoreShareViaProfileChange = false
|
||||
@@ -67,6 +68,11 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatModel.userAddress == nil, autoCreate {
|
||||
createAddress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Namespace private var bottomID
|
||||
@@ -212,26 +218,30 @@ struct UserAddressView: View {
|
||||
|
||||
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 deleteAddressButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
|
||||
@@ -21,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)}
|
||||
|
||||
@@ -55,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 {
|
||||
@@ -77,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 {
|
||||
@@ -90,12 +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: 38)
|
||||
}
|
||||
} footer: {
|
||||
Text("Tap to activate profile.")
|
||||
@@ -189,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 user 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
|
||||
@@ -213,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))
|
||||
}
|
||||
@@ -231,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))
|
||||
}
|
||||
@@ -255,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +327,8 @@ 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 {
|
||||
@@ -319,12 +346,19 @@ struct UserProfilesView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +409,9 @@ struct UserProfilesView: View {
|
||||
} else {
|
||||
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button("Delete", role: .destructive) {
|
||||
confirmDeleteUser(user)
|
||||
withAuth {
|
||||
confirmDeleteUser(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,20 +144,22 @@
|
||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; };
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; };
|
||||
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; };
|
||||
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
|
||||
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
|
||||
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
|
||||
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; };
|
||||
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a */; };
|
||||
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; };
|
||||
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; };
|
||||
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; };
|
||||
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a */; };
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */; };
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */; };
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
|
||||
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */; };
|
||||
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
||||
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; };
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; };
|
||||
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; };
|
||||
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
|
||||
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
|
||||
@@ -200,7 +202,9 @@
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
|
||||
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
|
||||
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
|
||||
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
|
||||
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
|
||||
@@ -436,7 +440,7 @@
|
||||
5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = "<group>"; };
|
||||
5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = "<group>"; };
|
||||
5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = "<group>"; };
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; wrapsLines = 0; };
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
|
||||
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = "<group>"; };
|
||||
5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -487,20 +491,22 @@
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = "<group>"; };
|
||||
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
|
||||
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
|
||||
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
|
||||
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
|
||||
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
|
||||
642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = "<group>"; };
|
||||
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a"; sourceTree = "<group>"; };
|
||||
642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; sourceTree = "<group>"; };
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
|
||||
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = "<group>"; };
|
||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
||||
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
||||
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
|
||||
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
|
||||
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
|
||||
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
|
||||
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
|
||||
@@ -544,7 +550,9 @@
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
|
||||
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
|
||||
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
|
||||
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
|
||||
@@ -657,14 +665,14 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */,
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */,
|
||||
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */,
|
||||
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */,
|
||||
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a in Frameworks */,
|
||||
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a in Frameworks */,
|
||||
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -741,6 +749,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
642BA82F2CEB3D4B005E9412 /* libffi.a */,
|
||||
642BA8302CEB3D4B005E9412 /* libgmp.a */,
|
||||
642BA8312CEB3D4B005E9412 /* libgmpxx.a */,
|
||||
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14-ghc9.6.3.a */,
|
||||
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.1-3FFlorLJSLlCbWWiG2Vp14.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -812,11 +825,6 @@
|
||||
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
|
||||
5C764E5C279C70B7000C6508 /* Libraries */,
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */,
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */,
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */,
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */,
|
||||
5CA059C2279559F40002BEB4 /* Shared */,
|
||||
5CDCAD462818589900503DA2 /* SimpleX NSE */,
|
||||
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
|
||||
@@ -875,13 +883,15 @@
|
||||
5CB0BA8C282711BC00B3292C /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */,
|
||||
5CB0BA8D2827126500B3292C /* OnboardingView.swift */,
|
||||
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */,
|
||||
5CB0BA992827FD8800B3292C /* HowItWorks.swift */,
|
||||
5CB0BA91282713FD00B3292C /* CreateProfile.swift */,
|
||||
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */,
|
||||
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */,
|
||||
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */,
|
||||
640743602CD360E600158442 /* ChooseServerOperators.swift */,
|
||||
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */,
|
||||
);
|
||||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
@@ -1056,8 +1066,10 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
|
||||
642BA82C2CE50495005E9412 /* NewServerView.swift */,
|
||||
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
|
||||
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
|
||||
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */,
|
||||
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
|
||||
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
|
||||
);
|
||||
@@ -1383,10 +1395,12 @@
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
|
||||
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
|
||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */,
|
||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
||||
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */,
|
||||
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */,
|
||||
@@ -1413,12 +1427,12 @@
|
||||
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
|
||||
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
|
||||
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
|
||||
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */,
|
||||
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
|
||||
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
|
||||
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */,
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
|
||||
@@ -1536,7 +1550,9 @@
|
||||
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
|
||||
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */,
|
||||
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */,
|
||||
184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */,
|
||||
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -1911,7 +1927,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1936,7 +1952,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES_THIN;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1960,7 +1976,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1985,7 +2001,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2001,11 +2017,11 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2021,11 +2037,11 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2046,7 +2062,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
@@ -2061,7 +2077,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -2083,7 +2099,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
@@ -2098,7 +2114,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -2120,7 +2136,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2146,7 +2162,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2171,7 +2187,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2197,7 +2213,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2222,7 +2238,7 @@
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -2237,7 +2253,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2256,7 +2272,7 @@
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -2271,7 +2287,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -72,9 +72,15 @@ public enum ChatCommand {
|
||||
case apiGetGroupLink(groupId: Int64)
|
||||
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
|
||||
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
|
||||
case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol)
|
||||
case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg])
|
||||
case apiTestProtoServer(userId: Int64, server: String)
|
||||
case apiGetServerOperators
|
||||
case apiSetServerOperators(operators: [ServerOperator])
|
||||
case apiGetUserServers(userId: Int64)
|
||||
case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers])
|
||||
case apiValidateServers(userId: Int64, userServers: [UserOperatorServers])
|
||||
case apiGetUsageConditions
|
||||
case apiSetConditionsNotified(conditionsId: Int64)
|
||||
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
|
||||
case apiGetChatItemTTL(userId: Int64)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
@@ -231,9 +237,15 @@ public enum ChatCommand {
|
||||
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
||||
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
|
||||
case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
|
||||
case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)"
|
||||
case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))"
|
||||
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
|
||||
case .apiGetServerOperators: return "/_operators"
|
||||
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
|
||||
case let .apiGetUserServers(userId): return "/_servers \(userId)"
|
||||
case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))"
|
||||
case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))"
|
||||
case .apiGetUsageConditions: return "/_conditions"
|
||||
case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)"
|
||||
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
|
||||
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
@@ -386,9 +398,15 @@ public enum ChatCommand {
|
||||
case .apiGetGroupLink: return "apiGetGroupLink"
|
||||
case .apiCreateMemberContact: return "apiCreateMemberContact"
|
||||
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
|
||||
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
|
||||
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
|
||||
case .apiTestProtoServer: return "apiTestProtoServer"
|
||||
case .apiGetServerOperators: return "apiGetServerOperators"
|
||||
case .apiSetServerOperators: return "apiSetServerOperators"
|
||||
case .apiGetUserServers: return "apiGetUserServers"
|
||||
case .apiSetUserServers: return "apiSetUserServers"
|
||||
case .apiValidateServers: return "apiValidateServers"
|
||||
case .apiGetUsageConditions: return "apiGetUsageConditions"
|
||||
case .apiSetConditionsNotified: return "apiSetConditionsNotified"
|
||||
case .apiAcceptConditions: return "apiAcceptConditions"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
@@ -475,10 +493,6 @@ public enum ChatCommand {
|
||||
func joinedIds(_ ids: [Int64]) -> String {
|
||||
ids.map { "\($0)" }.joined(separator: ",")
|
||||
}
|
||||
|
||||
func protoServersStr(_ servers: [ServerCfg]) -> String {
|
||||
encodeJSON(ProtoServersConfig(servers: servers))
|
||||
}
|
||||
|
||||
func chatItemTTLStr(seconds: Int64?) -> String {
|
||||
if let seconds = seconds {
|
||||
@@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case apiChats(user: UserRef, chats: [ChatData])
|
||||
case apiChat(user: UserRef, chat: ChatData)
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case userProtoServers(user: UserRef, servers: UserProtoServers)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
case serverOperatorConditions(conditions: ServerOperatorConditions)
|
||||
case userServers(user: UserRef, userServers: [UserOperatorServers])
|
||||
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
|
||||
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
|
||||
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
|
||||
case networkConfig(networkConfig: NetCfg)
|
||||
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
|
||||
@@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .apiChats: return "apiChats"
|
||||
case .apiChat: return "apiChat"
|
||||
case .chatItemInfo: return "chatItemInfo"
|
||||
case .userProtoServers: return "userProtoServers"
|
||||
case .serverTestResult: return "serverTestResult"
|
||||
case .serverOperatorConditions: return "serverOperators"
|
||||
case .userServers: return "userServers"
|
||||
case .userServersValidation: return "userServersValidation"
|
||||
case .usageConditions: return "usageConditions"
|
||||
case .chatItemTTL: return "chatItemTTL"
|
||||
case .networkConfig: return "networkConfig"
|
||||
case .contactInfo: return "contactInfo"
|
||||
@@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
|
||||
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
|
||||
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
|
||||
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
|
||||
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
|
||||
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
||||
case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
|
||||
@@ -1175,86 +1198,428 @@ public struct DBEncryptionConfig: Codable {
|
||||
public var newKey: String
|
||||
}
|
||||
|
||||
struct SMPServersConfig: Encodable {
|
||||
var smpServers: [ServerCfg]
|
||||
}
|
||||
|
||||
public enum ServerProtocol: String, Decodable {
|
||||
case smp
|
||||
case xftp
|
||||
}
|
||||
|
||||
public struct ProtoServersConfig: Codable {
|
||||
public var servers: [ServerCfg]
|
||||
public enum OperatorTag: String, Codable {
|
||||
case simplex = "simplex"
|
||||
case flux = "flux"
|
||||
case xyz = "xyz"
|
||||
case demo = "demo"
|
||||
}
|
||||
|
||||
public struct UserProtoServers: Decodable {
|
||||
public var serverProtocol: ServerProtocol
|
||||
public var protoServers: [ServerCfg]
|
||||
public var presetServers: [ServerCfg]
|
||||
public struct ServerOperatorInfo: Decodable {
|
||||
public var description: [String]
|
||||
public var website: String
|
||||
public var logo: String
|
||||
public var largeLogo: String
|
||||
public var logoDarkMode: String
|
||||
public var largeLogoDarkMode: String
|
||||
}
|
||||
|
||||
public struct ServerCfg: Identifiable, Equatable, Codable, Hashable {
|
||||
public let operatorsInfo: Dictionary<OperatorTag, ServerOperatorInfo> = [
|
||||
.simplex: ServerOperatorInfo(
|
||||
description: [
|
||||
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
|
||||
"SimpleX Chat Ltd develops the communication software for SimpleX network."
|
||||
],
|
||||
website: "https://simplex.chat",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
),
|
||||
.flux: ServerOperatorInfo(
|
||||
description: [
|
||||
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
|
||||
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
|
||||
],
|
||||
website: "https://runonflux.com",
|
||||
logo: "flux_logo_symbol",
|
||||
largeLogo: "flux_logo",
|
||||
logoDarkMode: "flux_logo_symbol",
|
||||
largeLogoDarkMode: "flux_logo-light"
|
||||
),
|
||||
.xyz: ServerOperatorInfo(
|
||||
description: ["XYZ servers"],
|
||||
website: "XYZ website",
|
||||
logo: "shield",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "shield",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
),
|
||||
.demo: ServerOperatorInfo(
|
||||
description: ["Demo operator"],
|
||||
website: "Demo website",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
)
|
||||
]
|
||||
|
||||
public struct UsageConditions: Decodable {
|
||||
public var conditionsId: Int64
|
||||
public var conditionsCommit: String
|
||||
public var notifiedAt: Date?
|
||||
public var createdAt: Date
|
||||
|
||||
public static var sampleData = UsageConditions(
|
||||
conditionsId: 1,
|
||||
conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c",
|
||||
notifiedAt: nil,
|
||||
createdAt: Date.now
|
||||
)
|
||||
}
|
||||
|
||||
public enum UsageConditionsAction: Decodable {
|
||||
case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool)
|
||||
case accepted(operators: [ServerOperator])
|
||||
|
||||
public var showNotice: Bool {
|
||||
switch self {
|
||||
case let .review(_, _, showNotice): showNotice
|
||||
case .accepted: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ServerOperatorConditions: Decodable {
|
||||
public var serverOperators: [ServerOperator]
|
||||
public var currentConditions: UsageConditions
|
||||
public var conditionsAction: UsageConditionsAction?
|
||||
|
||||
public static var empty = ServerOperatorConditions(
|
||||
serverOperators: [],
|
||||
currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now),
|
||||
conditionsAction: nil
|
||||
)
|
||||
}
|
||||
|
||||
public enum ConditionsAcceptance: Equatable, Codable, Hashable {
|
||||
case accepted(acceptedAt: Date?)
|
||||
// If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
|
||||
// No deadline indicates it's required to accept conditions for the operator to start using it.
|
||||
case required(deadline: Date?)
|
||||
|
||||
public var conditionsAccepted: Bool {
|
||||
switch self {
|
||||
case .accepted: true
|
||||
case .required: false
|
||||
}
|
||||
}
|
||||
|
||||
public var usageAllowed: Bool {
|
||||
switch self {
|
||||
case .accepted: true
|
||||
case let .required(deadline): deadline != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ServerOperator: Identifiable, Equatable, Codable {
|
||||
public var operatorId: Int64
|
||||
public var operatorTag: OperatorTag?
|
||||
public var tradeName: String
|
||||
public var legalName: String?
|
||||
public var serverDomains: [String]
|
||||
public var conditionsAcceptance: ConditionsAcceptance
|
||||
public var enabled: Bool
|
||||
public var smpRoles: ServerRoles
|
||||
public var xftpRoles: ServerRoles
|
||||
|
||||
public var id: Int64 { operatorId }
|
||||
|
||||
public static func == (l: ServerOperator, r: ServerOperator) -> Bool {
|
||||
l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName &&
|
||||
l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled &&
|
||||
l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles
|
||||
}
|
||||
|
||||
public var legalName_: String {
|
||||
legalName ?? tradeName
|
||||
}
|
||||
|
||||
public var info: ServerOperatorInfo {
|
||||
return if let operatorTag = operatorTag {
|
||||
operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo
|
||||
} else {
|
||||
ServerOperator.dummyOperatorInfo
|
||||
}
|
||||
}
|
||||
|
||||
public static let dummyOperatorInfo = ServerOperatorInfo(
|
||||
description: ["Default"],
|
||||
website: "Default",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
)
|
||||
|
||||
public func logo(_ colorScheme: ColorScheme) -> String {
|
||||
colorScheme == .light ? info.logo : info.logoDarkMode
|
||||
}
|
||||
|
||||
public func largeLogo(_ colorScheme: ColorScheme) -> String {
|
||||
colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode
|
||||
}
|
||||
|
||||
public static var sampleData1 = ServerOperator(
|
||||
operatorId: 1,
|
||||
operatorTag: .simplex,
|
||||
tradeName: "SimpleX Chat",
|
||||
legalName: "SimpleX Chat Ltd",
|
||||
serverDomains: ["simplex.im"],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
enabled: true,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
)
|
||||
|
||||
public static var sampleData2 = ServerOperator(
|
||||
operatorId: 2,
|
||||
operatorTag: .xyz,
|
||||
tradeName: "XYZ",
|
||||
legalName: nil,
|
||||
serverDomains: ["xyz.com"],
|
||||
conditionsAcceptance: .required(deadline: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: false, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: false, proxy: true)
|
||||
)
|
||||
|
||||
public static var sampleData3 = ServerOperator(
|
||||
operatorId: 3,
|
||||
operatorTag: .demo,
|
||||
tradeName: "Demo",
|
||||
legalName: nil,
|
||||
serverDomains: ["demo.com"],
|
||||
conditionsAcceptance: .required(deadline: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: true, proxy: false),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: false)
|
||||
)
|
||||
}
|
||||
|
||||
public struct ServerRoles: Equatable, Codable {
|
||||
public var storage: Bool
|
||||
public var proxy: Bool
|
||||
}
|
||||
|
||||
public struct UserOperatorServers: Identifiable, Equatable, Codable {
|
||||
public var `operator`: ServerOperator?
|
||||
public var smpServers: [UserServer]
|
||||
public var xftpServers: [UserServer]
|
||||
|
||||
public var id: String {
|
||||
if let op = self.operator {
|
||||
"\(op.operatorId)"
|
||||
} else {
|
||||
"nil operator"
|
||||
}
|
||||
}
|
||||
|
||||
public var operator_: ServerOperator {
|
||||
get {
|
||||
self.operator ?? ServerOperator(
|
||||
operatorId: 0,
|
||||
operatorTag: nil,
|
||||
tradeName: "",
|
||||
legalName: "",
|
||||
serverDomains: [],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
)
|
||||
}
|
||||
set { `operator` = newValue }
|
||||
}
|
||||
|
||||
public static var sampleData1 = UserOperatorServers(
|
||||
operator: ServerOperator.sampleData1,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
)
|
||||
|
||||
public static var sampleDataNilOperator = UserOperatorServers(
|
||||
operator: nil,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
)
|
||||
}
|
||||
|
||||
public enum UserServersError: Decodable {
|
||||
case noServers(protocol: ServerProtocol, user: UserRef?)
|
||||
case storageMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
|
||||
|
||||
public var globalError: String? {
|
||||
switch self {
|
||||
case let .noServers(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
case let .storageMissing(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
case let .proxyMissing(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var globalSMPError: String? {
|
||||
switch self {
|
||||
case let .noServers(.smp, user):
|
||||
let text = NSLocalizedString("No message servers.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .storageMissing(.smp, user):
|
||||
let text = NSLocalizedString("No servers to receive messages.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .proxyMissing(.smp, user):
|
||||
let text = NSLocalizedString("No servers for private message routing.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var globalXFTPError: String? {
|
||||
switch self {
|
||||
case let .noServers(.xftp, user):
|
||||
let text = NSLocalizedString("No media & file servers.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .storageMissing(.xftp, user):
|
||||
let text = NSLocalizedString("No servers to send files.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .proxyMissing(.xftp, user):
|
||||
let text = NSLocalizedString("No servers to receive files.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func userStr(_ user: UserRef) -> String {
|
||||
String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserServer: Identifiable, Equatable, Codable, Hashable {
|
||||
public var serverId: Int64?
|
||||
public var server: String
|
||||
public var preset: Bool
|
||||
public var tested: Bool?
|
||||
public var enabled: Bool
|
||||
public var deleted: Bool
|
||||
var createdAt = Date()
|
||||
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
|
||||
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||
// In any case, "trusted/known" servers are out of scope of this change
|
||||
|
||||
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
|
||||
public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) {
|
||||
self.serverId = serverId
|
||||
self.server = server
|
||||
self.preset = preset
|
||||
self.tested = tested
|
||||
self.enabled = enabled
|
||||
self.deleted = deleted
|
||||
}
|
||||
|
||||
public static func == (l: ServerCfg, r: ServerCfg) -> Bool {
|
||||
l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled
|
||||
public static func == (l: UserServer, r: UserServer) -> Bool {
|
||||
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
|
||||
l.enabled == r.enabled && l.deleted == r.deleted
|
||||
}
|
||||
|
||||
public var id: String { "\(server) \(createdAt)" }
|
||||
|
||||
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false)
|
||||
public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false)
|
||||
|
||||
public var isEmpty: Bool {
|
||||
server.trimmingCharacters(in: .whitespaces) == ""
|
||||
}
|
||||
|
||||
public struct SampleData {
|
||||
public var preset: ServerCfg
|
||||
public var custom: ServerCfg
|
||||
public var untested: ServerCfg
|
||||
public var preset: UserServer
|
||||
public var custom: UserServer
|
||||
public var untested: UserServer
|
||||
public var xftpPreset: UserServer
|
||||
}
|
||||
|
||||
public static var sampleData = SampleData(
|
||||
preset: ServerCfg(
|
||||
preset: UserServer(
|
||||
serverId: 1,
|
||||
server: "smp://abcd@smp8.simplex.im",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
deleted: false
|
||||
),
|
||||
custom: ServerCfg(
|
||||
custom: UserServer(
|
||||
serverId: 2,
|
||||
server: "smp://abcd@smp9.simplex.im",
|
||||
preset: false,
|
||||
tested: false,
|
||||
enabled: false
|
||||
enabled: false,
|
||||
deleted: false
|
||||
),
|
||||
untested: ServerCfg(
|
||||
untested: UserServer(
|
||||
serverId: 3,
|
||||
server: "smp://abcd@smp10.simplex.im",
|
||||
preset: false,
|
||||
tested: nil,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
deleted: false
|
||||
),
|
||||
xftpPreset: UserServer(
|
||||
serverId: 4,
|
||||
server: "xftp://abcd@xftp8.simplex.im",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true,
|
||||
deleted: false
|
||||
)
|
||||
)
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case serverId
|
||||
case server
|
||||
case preset
|
||||
case tested
|
||||
case enabled
|
||||
case deleted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable {
|
||||
public var hostMode: HostMode = .publicHost
|
||||
public var requiredHostMode = true
|
||||
public var sessionMode = TransportSessionMode.user
|
||||
public var smpProxyMode: SMPProxyMode = .unknown
|
||||
public var smpProxyMode: SMPProxyMode = .always
|
||||
public var smpProxyFallback: SMPProxyFallback = .allowProtected
|
||||
public var smpWebPort = false
|
||||
public var tcpConnectTimeout: Int // microseconds
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import chat.simplex.common.AppScreen
|
||||
import chat.simplex.common.model.clear
|
||||
import chat.simplex.common.model.clearAndNotify
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.ui.platform.LocalContext as LocalContext1
|
||||
import chat.simplex.res.MR
|
||||
@@ -75,7 +76,7 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
|
||||
} else if (chatModel.chatId.value != null) {
|
||||
// Since no modals are open, the problem is probably in ChatView
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
} else {
|
||||
// ChatList, nothing to do. Maybe to show other view except ChatList
|
||||
}
|
||||
|
||||
@@ -44,3 +44,10 @@ actual fun LocalWindowWidth(): Dp {
|
||||
(rect.width() / density).dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun LocalWindowHeight(): Dp {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
return with(density) { view.height.toDp() }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import chat.simplex.common.model.UserServer
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
|
||||
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun AppScreen() {
|
||||
@@ -194,7 +192,15 @@ fun MainScreen() {
|
||||
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
|
||||
OnboardingStage.LinkAMobile -> LinkAMobile()
|
||||
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
|
||||
OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
|
||||
OnboardingStage.Step3_ChooseServerOperators -> {
|
||||
val modalData = remember { ModalData() }
|
||||
modalData.ChooseServerOperators(true)
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
}
|
||||
// Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped
|
||||
OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel)
|
||||
OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
}
|
||||
@@ -425,8 +431,10 @@ fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
.fillMaxSize()
|
||||
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
|
||||
ModalManager.start.closeModals()
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (chatModel.centerPanelBackgroundClickHandler == null || chatModel.centerPanelBackgroundClickHandler?.invoke() == false) {
|
||||
ModalManager.start.closeModals()
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.model.ChatModel.chatItemsChangesListener
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrationToDeviceState
|
||||
import chat.simplex.common.views.migration.MigrationToState
|
||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.collections.removeAll as remAll
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.serialization.*
|
||||
@@ -35,6 +37,7 @@ import java.net.URI
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.*
|
||||
|
||||
@@ -64,7 +67,14 @@ object ChatModel {
|
||||
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
|
||||
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
|
||||
* If you use api call to get the items, use just [add] instead of [addAndNotify].
|
||||
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
|
||||
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
|
||||
// set listener here that will be notified on every add/delete of a chat item
|
||||
var chatItemsChangesListener: ChatItemsChangesListener? = null
|
||||
val chatState = ActiveChatState()
|
||||
// rhId, chatId
|
||||
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
|
||||
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
|
||||
@@ -135,6 +145,8 @@ object ChatModel {
|
||||
val clipboardHasText = mutableStateOf(false)
|
||||
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
|
||||
|
||||
val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)
|
||||
|
||||
val updatingProgress = mutableStateOf(null as Float?)
|
||||
var updatingRequest: Closeable? = null
|
||||
|
||||
@@ -155,6 +167,9 @@ object ChatModel {
|
||||
val processedCriticalError: ProcessedErrors<AgentErrorType.CRITICAL> = ProcessedErrors(60_000)
|
||||
val processedInternalError: ProcessedErrors<AgentErrorType.INTERNAL> = ProcessedErrors(20_000)
|
||||
|
||||
// return true if you handled the click
|
||||
var centerPanelBackgroundClickHandler: (() -> Boolean)? = null
|
||||
|
||||
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||
currentUser.value
|
||||
} else {
|
||||
@@ -216,6 +231,15 @@ object ChatModel {
|
||||
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0)
|
||||
}
|
||||
|
||||
private suspend fun reorderChat(chat: Chat, toIndex: Int) {
|
||||
val newChats = SnapshotStateList<Chat>()
|
||||
newChats.addAll(chats.value)
|
||||
newChats.remove(chat)
|
||||
newChats.add(index = toIndex, chat)
|
||||
chats.replaceAll(newChats)
|
||||
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = toIndex)
|
||||
}
|
||||
|
||||
fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) {
|
||||
val i = getChatIndex(rhId, cInfo.id)
|
||||
if (i >= 0) {
|
||||
@@ -317,7 +341,7 @@ object ChatModel {
|
||||
chat.chatStats
|
||||
)
|
||||
if (appPlatform.isDesktop && cItem.chatDir.sent) {
|
||||
addChat(chats.removeAt(i))
|
||||
reorderChat(chats[i], 0)
|
||||
} else {
|
||||
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i)
|
||||
}
|
||||
@@ -330,9 +354,9 @@ object ChatModel {
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.value.none { it.id == cItem.id }) {
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
|
||||
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
chatItems.addAndNotify(cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,7 +401,7 @@ object ChatModel {
|
||||
} else {
|
||||
cItem
|
||||
}
|
||||
chatItems.add(ci)
|
||||
chatItems.addAndNotify(ci)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
@@ -416,7 +440,7 @@ object ChatModel {
|
||||
}
|
||||
// remove from current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
chatItems.removeAll {
|
||||
chatItems.removeAllAndNotify {
|
||||
// We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted
|
||||
// (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id)
|
||||
val remove = it.id == cItem.id && it.meta.createdAt == cItem.meta.createdAt
|
||||
@@ -436,7 +460,7 @@ object ChatModel {
|
||||
// clear current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
chatItemStatuses.clear()
|
||||
chatItems.clear()
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,14 +631,14 @@ object ChatModel {
|
||||
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatItems.add(cItem)
|
||||
chatItems.addAndNotify(cItem)
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.removeLast()
|
||||
chatItems.removeLastAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,11 +646,17 @@ object ChatModel {
|
||||
val cInfo = chatInfo
|
||||
var markedRead = 0
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
val items = chatItems.value
|
||||
while (i < items.size) {
|
||||
var i = items.lastIndex
|
||||
val itemIdsFromRange = if (range != null) {
|
||||
(range.from .. range.to).toMutableSet()
|
||||
} else {
|
||||
mutableSetOf()
|
||||
}
|
||||
val markedReadIds = mutableSetOf<Long>()
|
||||
while (i >= 0) {
|
||||
val item = items[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || itemIdsFromRange.contains(item.id))) {
|
||||
val newItem = item.withStatus(CIStatus.RcvRead())
|
||||
items[i] = newItem
|
||||
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
|
||||
@@ -634,10 +664,17 @@ object ChatModel {
|
||||
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
|
||||
)
|
||||
}
|
||||
markedReadIds.add(item.id)
|
||||
markedRead++
|
||||
if (range != null) {
|
||||
itemIdsFromRange.remove(item.id)
|
||||
// already set all needed items as read, can finish the loop
|
||||
if (itemIdsFromRange.isEmpty()) break
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
i--
|
||||
}
|
||||
chatItemsChangesListener?.read(if (range != null) markedReadIds else null, items)
|
||||
}
|
||||
return markedRead
|
||||
}
|
||||
@@ -684,17 +721,6 @@ object ChatModel {
|
||||
return count to ns
|
||||
}
|
||||
|
||||
// returns the index of the passed item and the next item (it has smaller index)
|
||||
fun getNextChatItem(ci: ChatItem): Pair<Int?, ChatItem?> {
|
||||
val i = getChatItemIndexOrNull(ci)
|
||||
return if (i != null) {
|
||||
val reversedChatItems = chatItems.asReversed()
|
||||
i to if (i > 0) reversedChatItems[i - 1] else null
|
||||
} else {
|
||||
null to null
|
||||
}
|
||||
}
|
||||
|
||||
// returns the index of the first item in the same merged group (the first hidden item)
|
||||
// and the previous visible item with another merge category
|
||||
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
|
||||
@@ -738,7 +764,7 @@ object ChatModel {
|
||||
fun replaceConnReqView(id: String, withId: String) {
|
||||
if (id == showingInvitation.value?.connId) {
|
||||
showingInvitation.value = null
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = withId
|
||||
ModalManager.start.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
@@ -748,7 +774,7 @@ object ChatModel {
|
||||
fun dismissConnReqView(id: String) {
|
||||
if (id == showingInvitation.value?.connId) {
|
||||
showingInvitation.value = null
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = null
|
||||
// Close NewChatView
|
||||
ModalManager.start.closeModals()
|
||||
@@ -798,6 +824,15 @@ object ChatModel {
|
||||
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
|
||||
}
|
||||
|
||||
interface ChatItemsChangesListener {
|
||||
// pass null itemIds if the whole chat now read
|
||||
fun read(itemIds: Set<Long>?, newItems: List<ChatItem>)
|
||||
fun added(item: Pair<Long, Boolean>, index: Int)
|
||||
// itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not)
|
||||
fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>)
|
||||
fun cleared()
|
||||
}
|
||||
|
||||
data class ShowingInvitation(
|
||||
val connId: String,
|
||||
val connReq: String,
|
||||
@@ -1293,6 +1328,12 @@ data class Contact(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NavigationInfo(
|
||||
val afterUnread: Int = 0,
|
||||
val afterTotal: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ContactStatus {
|
||||
@SerialName("active") Active,
|
||||
@@ -2279,12 +2320,24 @@ data class ChatItem (
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.add(index: Int, elem: T) {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); add(index, elem) }
|
||||
fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
|
||||
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.add(elem: T) {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); add(elem) }
|
||||
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem) {
|
||||
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) }
|
||||
}
|
||||
|
||||
fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
|
||||
value = SnapshotStateList<Chat>().apply { addAll(value); add(elem) }
|
||||
}
|
||||
|
||||
// For some reason, Kotlin version crashes if the list is empty
|
||||
fun <T> MutableList<T>.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate)
|
||||
|
||||
// Adds item to chatItems and notifies a listener about newly added item
|
||||
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem) {
|
||||
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
|
||||
@@ -2295,28 +2348,59 @@ fun <T> MutableState<SnapshotStateList<T>>.addAll(elems: List<T>) {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); addAll(elems) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.removeAll(block: (T) -> Boolean) {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); removeAll(block) }
|
||||
fun MutableState<SnapshotStateList<Chat>>.removeAll(block: (Chat) -> Boolean) {
|
||||
value = SnapshotStateList<Chat>().apply { addAll(value); removeAll(block) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
|
||||
val new = SnapshotStateList<T>()
|
||||
// Removes item(s) from chatItems and notifies a listener about removed item(s)
|
||||
fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatItem) -> Boolean) {
|
||||
val toRemove = ArrayList<Triple<Long, Int, Boolean>>()
|
||||
value = SnapshotStateList<ChatItem>().apply {
|
||||
addAll(value)
|
||||
var i = 0
|
||||
removeAll {
|
||||
val remove = block(it)
|
||||
if (remove) toRemove.add(Triple(it.id, i, it.isRcvNew))
|
||||
i++
|
||||
remove
|
||||
}
|
||||
}
|
||||
if (toRemove.isNotEmpty()) {
|
||||
chatItemsChangesListener?.removed(toRemove, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun MutableState<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
|
||||
val new = SnapshotStateList<Chat>()
|
||||
new.addAll(value)
|
||||
val res = new.removeAt(index)
|
||||
value = new
|
||||
return res
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
|
||||
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
|
||||
val removed: Triple<Long, Int, Boolean>
|
||||
value = SnapshotStateList<ChatItem>().apply {
|
||||
addAll(value)
|
||||
val remIndex = lastIndex
|
||||
val rem = removeLast()
|
||||
removed = Triple(rem.id, remIndex, rem.isRcvNew)
|
||||
}
|
||||
chatItemsChangesListener?.removed(listOf(removed), value)
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
|
||||
value = SnapshotStateList<T>().apply { addAll(elems) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.clear() {
|
||||
value = SnapshotStateList<T>()
|
||||
fun MutableState<SnapshotStateList<Chat>>.clear() {
|
||||
value = SnapshotStateList()
|
||||
}
|
||||
|
||||
// Removes all chatItems and notifies a listener about it
|
||||
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
|
||||
value = SnapshotStateList()
|
||||
chatItemsChangesListener?.cleared()
|
||||
}
|
||||
|
||||
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()
|
||||
@@ -2488,6 +2572,13 @@ fun localTimestamp(t: Instant): String {
|
||||
return ts.toJavaLocalDateTime().format(dateFormatter)
|
||||
}
|
||||
|
||||
fun localDate(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val ts: LocalDateTime = t.toLocalDateTime(tz)
|
||||
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
return ts.toJavaLocalDateTime().format(dateFormatter)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIStatus {
|
||||
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
||||
|
||||
@@ -22,13 +22,16 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
|
||||
import chat.simplex.common.views.migration.MigrationFileLinkData
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.serverHostname
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.charleskorn.kaml.YamlConfiguration
|
||||
import chat.simplex.res.MR
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -179,6 +182,7 @@ class AppPreferences {
|
||||
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
|
||||
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
|
||||
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
|
||||
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
|
||||
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
|
||||
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
|
||||
val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } }
|
||||
@@ -254,6 +258,7 @@ class AppPreferences {
|
||||
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
|
||||
laNoticeShown to false,
|
||||
oneHandUICardShown to false,
|
||||
addressCreationCardShown to false,
|
||||
liveMessageAlertShown to false,
|
||||
showHiddenProfilesNotice to true,
|
||||
showMuteProfileAlert to true,
|
||||
@@ -408,6 +413,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
|
||||
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
|
||||
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
|
||||
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
|
||||
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
|
||||
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
|
||||
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
|
||||
@@ -865,11 +871,15 @@ object ChatController {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
|
||||
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search))
|
||||
if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh)
|
||||
if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
|
||||
if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
|
||||
showQuotedItemDoesNotExistAlert()
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -955,36 +965,6 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? {
|
||||
val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null }
|
||||
val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol))
|
||||
return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) }
|
||||
else {
|
||||
Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List<ServerCfg>): Boolean {
|
||||
val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false }
|
||||
val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers),
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique)
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? {
|
||||
val userId = currentUserId("testProtoServer")
|
||||
val r = sendCmd(rh, CC.APITestProtoServer(userId, server))
|
||||
@@ -997,6 +977,106 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiGetServerOperators())
|
||||
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setServerOperators(rh: Long?, operators: List<ServerOperator>): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiSetServerOperators(operators))
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUserServers(rh: Long?): List<UserOperatorServers>? {
|
||||
val userId = currentUserId("getUserServers")
|
||||
val r = sendCmd(rh, CC.ApiGetUserServers(userId))
|
||||
return when (r) {
|
||||
is CR.UserServers -> r.userServers
|
||||
else -> {
|
||||
Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserServers(rh: Long?, userServers: List<UserOperatorServers>): Boolean {
|
||||
val userId = currentUserId("setUserServers")
|
||||
val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.failed_to_save_servers),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validateServers(rh: Long?, userServers: List<UserOperatorServers>): List<UserServersError>? {
|
||||
val userId = currentUserId("validateServers")
|
||||
val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers))
|
||||
return when (r) {
|
||||
is CR.UserServersValidation -> r.serverErrors
|
||||
else -> {
|
||||
Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUsageConditions(rh: Long?): Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>? {
|
||||
val r = sendCmd(rh, CC.ApiGetUsageConditions())
|
||||
return when (r) {
|
||||
is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions)
|
||||
else -> {
|
||||
Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean {
|
||||
val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List<Long>): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds))
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.error_accepting_operator_conditions),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
|
||||
val userId = currentUserId("getChatItemTTL")
|
||||
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
|
||||
@@ -2858,7 +2938,7 @@ object ChatController {
|
||||
chatModel.users.addAll(users)
|
||||
chatModel.currentUser.value = user
|
||||
if (user == null) {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withChats {
|
||||
chats.clear()
|
||||
popChatCollector.clear()
|
||||
@@ -3029,9 +3109,15 @@ sealed class CC {
|
||||
class APIGetGroupLink(val groupId: Long): CC()
|
||||
class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC()
|
||||
class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC()
|
||||
class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List<ServerCfg>): CC()
|
||||
class APITestProtoServer(val userId: Long, val server: String): CC()
|
||||
class ApiGetServerOperators(): CC()
|
||||
class ApiSetServerOperators(val operators: List<ServerOperator>): CC()
|
||||
class ApiGetUserServers(val userId: Long): CC()
|
||||
class ApiSetUserServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
|
||||
class ApiValidateServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
|
||||
class ApiGetUsageConditions(): CC()
|
||||
class ApiSetConditionsNotified(val conditionsId: Long): CC()
|
||||
class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List<Long>): CC()
|
||||
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
|
||||
class APIGetChatItemTTL(val userId: Long): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
@@ -3189,9 +3275,15 @@ sealed class CC {
|
||||
is APIGetGroupLink -> "/_get link #$groupId"
|
||||
is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId"
|
||||
is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}"
|
||||
is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}"
|
||||
is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}"
|
||||
is APITestProtoServer -> "/_server test $userId $server"
|
||||
is ApiGetServerOperators -> "/_operators"
|
||||
is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}"
|
||||
is ApiGetUserServers -> "/_servers $userId"
|
||||
is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}"
|
||||
is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}"
|
||||
is ApiGetUsageConditions -> "/_conditions"
|
||||
is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}"
|
||||
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
|
||||
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
|
||||
is APIGetChatItemTTL -> "/_ttl $userId"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
@@ -3334,9 +3426,15 @@ sealed class CC {
|
||||
is APIGetGroupLink -> "apiGetGroupLink"
|
||||
is APICreateMemberContact -> "apiCreateMemberContact"
|
||||
is APISendMemberContactInvitation -> "apiSendMemberContactInvitation"
|
||||
is APIGetUserProtoServers -> "apiGetUserProtoServers"
|
||||
is APISetUserProtoServers -> "apiSetUserProtoServers"
|
||||
is APITestProtoServer -> "testProtoServer"
|
||||
is ApiGetServerOperators -> "apiGetServerOperators"
|
||||
is ApiSetServerOperators -> "apiSetServerOperators"
|
||||
is ApiGetUserServers -> "apiGetUserServers"
|
||||
is ApiSetUserServers -> "apiSetUserServers"
|
||||
is ApiValidateServers -> "apiValidateServers"
|
||||
is ApiGetUsageConditions -> "apiGetUsageConditions"
|
||||
is ApiSetConditionsNotified -> "apiSetConditionsNotified"
|
||||
is ApiAcceptConditions -> "apiAcceptConditions"
|
||||
is APISetChatItemTTL -> "apiSetChatItemTTL"
|
||||
is APIGetChatItemTTL -> "apiGetChatItemTTL"
|
||||
is APISetNetworkConfig -> "apiSetNetworkConfig"
|
||||
@@ -3420,7 +3518,7 @@ sealed class CC {
|
||||
is GetAgentServersSummary -> "getAgentServersSummary"
|
||||
}
|
||||
|
||||
class ItemRange(val from: Long, val to: Long)
|
||||
data class ItemRange(val from: Long, val to: Long)
|
||||
|
||||
fun chatItemTTLStr(seconds: Long?): String {
|
||||
if (seconds == null) return "none"
|
||||
@@ -3451,8 +3549,6 @@ sealed class CC {
|
||||
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
|
||||
|
||||
fun protoServersStr(servers: List<ServerCfg>) = json.encodeToString(ProtoServersConfig(servers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3468,15 +3564,19 @@ sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Before(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Around(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Initial(val count: Int): ChatPagination()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Last -> "count=${this.count}"
|
||||
is After -> "after=${this.chatItemId} count=${this.count}"
|
||||
is Before -> "before=${this.chatItemId} count=${this.count}"
|
||||
is Around -> "around=${this.chatItemId} count=${this.count}"
|
||||
is Initial -> "initial=${this.count}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INITIAL_COUNT = 100
|
||||
val INITIAL_COUNT = if (appPlatform.isDesktop) 100 else 75
|
||||
const val PRELOAD_COUNT = 100
|
||||
const val UNTIL_PRELOAD_COUNT = 50
|
||||
}
|
||||
@@ -3498,24 +3598,350 @@ enum class ServerProtocol {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProtoServersConfig(
|
||||
val servers: List<ServerCfg>
|
||||
enum class OperatorTag {
|
||||
@SerialName("simplex") SimpleX,
|
||||
@SerialName("flux") Flux,
|
||||
@SerialName("xyz") XYZ,
|
||||
@SerialName("demo") Demo
|
||||
}
|
||||
|
||||
data class ServerOperatorInfo(
|
||||
val description: List<String>,
|
||||
val website: String,
|
||||
val logo: ImageResource,
|
||||
val largeLogo: ImageResource,
|
||||
val logoDarkMode: ImageResource,
|
||||
val largeLogoDarkMode: ImageResource
|
||||
)
|
||||
val operatorsInfo: Map<OperatorTag, ServerOperatorInfo> = mapOf(
|
||||
OperatorTag.SimpleX to ServerOperatorInfo(
|
||||
description = listOf(
|
||||
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
|
||||
"SimpleX Chat Ltd develops the communication software for SimpleX network."
|
||||
),
|
||||
website = "https://simplex.chat",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
),
|
||||
OperatorTag.Flux to ServerOperatorInfo(
|
||||
description = listOf(
|
||||
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
|
||||
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
|
||||
),
|
||||
website = "https://runonflux.com",
|
||||
logo = MR.images.flux_logo_symbol,
|
||||
largeLogo = MR.images.flux_logo,
|
||||
logoDarkMode = MR.images.flux_logo_symbol,
|
||||
largeLogoDarkMode = MR.images.flux_logo_light
|
||||
),
|
||||
OperatorTag.XYZ to ServerOperatorInfo(
|
||||
description = listOf("XYZ servers"),
|
||||
website = "XYZ website",
|
||||
logo = MR.images.shield,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.shield,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
),
|
||||
OperatorTag.Demo to ServerOperatorInfo(
|
||||
description = listOf("Demo operator"),
|
||||
website = "Demo website",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProtocolServers(
|
||||
val serverProtocol: ServerProtocol,
|
||||
val protoServers: List<ServerCfg>,
|
||||
val presetServers: List<ServerCfg>,
|
||||
data class UsageConditionsDetail(
|
||||
val conditionsId: Long,
|
||||
val conditionsCommit: String,
|
||||
val notifiedAt: Instant?,
|
||||
val createdAt: Instant
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = UsageConditionsDetail(
|
||||
conditionsId = 1,
|
||||
conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c",
|
||||
notifiedAt = null,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class UsageConditionsAction {
|
||||
@Serializable @SerialName("review") data class Review(val operators: List<ServerOperator>, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction()
|
||||
@Serializable @SerialName("accepted") data class Accepted(val operators: List<ServerOperator>) : UsageConditionsAction()
|
||||
|
||||
val shouldShowNotice: Boolean
|
||||
get() = when (this) {
|
||||
is Review -> showNotice
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerOperatorConditionsDetail(
|
||||
val serverOperators: List<ServerOperator>,
|
||||
val currentConditions: UsageConditionsDetail,
|
||||
val conditionsAction: UsageConditionsAction?
|
||||
) {
|
||||
companion object {
|
||||
val empty = ServerOperatorConditionsDetail(
|
||||
serverOperators = emptyList(),
|
||||
currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()),
|
||||
conditionsAction = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable()
|
||||
sealed class ConditionsAcceptance {
|
||||
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance()
|
||||
@Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance()
|
||||
|
||||
val conditionsAccepted: Boolean
|
||||
get() = when (this) {
|
||||
is Accepted -> true
|
||||
is Required -> false
|
||||
}
|
||||
|
||||
val usageAllowed: Boolean
|
||||
get() = when (this) {
|
||||
is Accepted -> true
|
||||
is Required -> this.deadline != null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerOperator(
|
||||
val operatorId: Long,
|
||||
val operatorTag: OperatorTag?,
|
||||
val tradeName: String,
|
||||
val legalName: String?,
|
||||
val serverDomains: List<String>,
|
||||
val conditionsAcceptance: ConditionsAcceptance,
|
||||
val enabled: Boolean,
|
||||
val smpRoles: ServerRoles,
|
||||
val xftpRoles: ServerRoles,
|
||||
) {
|
||||
companion object {
|
||||
val dummyOperatorInfo = ServerOperatorInfo(
|
||||
description = listOf("Default"),
|
||||
website = "Default",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
)
|
||||
|
||||
val sampleData1 = ServerOperator(
|
||||
operatorId = 1,
|
||||
operatorTag = OperatorTag.SimpleX,
|
||||
tradeName = "SimpleX Chat",
|
||||
legalName = "SimpleX Chat Ltd",
|
||||
serverDomains = listOf("simplex.im"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null),
|
||||
enabled = true,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
)
|
||||
|
||||
val sampleData2 = ServerOperator(
|
||||
operatorId = 2,
|
||||
operatorTag = OperatorTag.XYZ,
|
||||
tradeName = "XYZ",
|
||||
legalName = null,
|
||||
serverDomains = listOf("xyz.com"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = false, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = false, proxy = true)
|
||||
)
|
||||
|
||||
val sampleData3 = ServerOperator(
|
||||
operatorId = 3,
|
||||
operatorTag = OperatorTag.Demo,
|
||||
tradeName = "Demo",
|
||||
legalName = null,
|
||||
serverDomains = listOf("demo.com"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = false),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = false)
|
||||
)
|
||||
}
|
||||
|
||||
val id: Long
|
||||
get() = operatorId
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ServerOperator) return false
|
||||
return other.operatorId == this.operatorId &&
|
||||
other.operatorTag == this.operatorTag &&
|
||||
other.tradeName == this.tradeName &&
|
||||
other.legalName == this.legalName &&
|
||||
other.serverDomains == this.serverDomains &&
|
||||
other.conditionsAcceptance == this.conditionsAcceptance &&
|
||||
other.enabled == this.enabled &&
|
||||
other.smpRoles == this.smpRoles &&
|
||||
other.xftpRoles == this.xftpRoles
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = operatorId.hashCode()
|
||||
result = 31 * result + (operatorTag?.hashCode() ?: 0)
|
||||
result = 31 * result + tradeName.hashCode()
|
||||
result = 31 * result + (legalName?.hashCode() ?: 0)
|
||||
result = 31 * result + serverDomains.hashCode()
|
||||
result = 31 * result + conditionsAcceptance.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + smpRoles.hashCode()
|
||||
result = 31 * result + xftpRoles.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
val legalName_: String
|
||||
get() = legalName ?: tradeName
|
||||
|
||||
val info: ServerOperatorInfo get() {
|
||||
return if (this.operatorTag != null) {
|
||||
operatorsInfo[this.operatorTag] ?: dummyOperatorInfo
|
||||
} else {
|
||||
dummyOperatorInfo
|
||||
}
|
||||
}
|
||||
|
||||
val logo: ImageResource
|
||||
@Composable
|
||||
get() {
|
||||
return if (isInDarkTheme()) info.logoDarkMode else info.logo
|
||||
}
|
||||
|
||||
val largeLogo: ImageResource
|
||||
@Composable
|
||||
get() {
|
||||
return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerRoles(
|
||||
val storage: Boolean,
|
||||
val proxy: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerCfg(
|
||||
data class UserOperatorServers(
|
||||
val operator: ServerOperator?,
|
||||
val smpServers: List<UserServer>,
|
||||
val xftpServers: List<UserServer>
|
||||
) {
|
||||
val id: String
|
||||
get() = operator?.operatorId?.toString() ?: "nil operator"
|
||||
|
||||
val operator_: ServerOperator
|
||||
get() = operator ?: ServerOperator(
|
||||
operatorId = 0,
|
||||
operatorTag = null,
|
||||
tradeName = "",
|
||||
legalName = null,
|
||||
serverDomains = emptyList(),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
)
|
||||
|
||||
companion object {
|
||||
val sampleData1 = UserOperatorServers(
|
||||
operator = ServerOperator.sampleData1,
|
||||
smpServers = listOf(UserServer.sampleData.preset),
|
||||
xftpServers = listOf(UserServer.sampleData.xftpPreset)
|
||||
)
|
||||
|
||||
val sampleDataNilOperator = UserOperatorServers(
|
||||
operator = null,
|
||||
smpServers = listOf(UserServer.sampleData.preset),
|
||||
xftpServers = listOf(UserServer.sampleData.xftpPreset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class UserServersError {
|
||||
@Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError()
|
||||
|
||||
val globalError: String?
|
||||
get() = when (this.protocol_) {
|
||||
ServerProtocol.SMP -> globalSMPError
|
||||
ServerProtocol.XFTP -> globalXFTPError
|
||||
}
|
||||
|
||||
private val protocol_: ServerProtocol
|
||||
get() = when (this) {
|
||||
is NoServers -> this.protocol
|
||||
is StorageMissing -> this.protocol
|
||||
is ProxyMissing -> this.protocol
|
||||
is DuplicateServer -> this.protocol
|
||||
}
|
||||
|
||||
val globalSMPError: String?
|
||||
get() = if (this.protocol_ == ServerProtocol.SMP) {
|
||||
when (this) {
|
||||
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured)
|
||||
|
||||
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured_for_receiving)
|
||||
|
||||
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing)
|
||||
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val globalXFTPError: String?
|
||||
get() = if (this.protocol_ == ServerProtocol.XFTP) {
|
||||
when (this) {
|
||||
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured)
|
||||
|
||||
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured_for_sending)
|
||||
|
||||
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing)
|
||||
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun userStr(user: UserRef): String {
|
||||
return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserServer(
|
||||
val remoteHostId: Long?,
|
||||
val serverId: Long?,
|
||||
val server: String,
|
||||
val preset: Boolean,
|
||||
val tested: Boolean? = null,
|
||||
val enabled: Boolean
|
||||
val enabled: Boolean,
|
||||
val deleted: Boolean
|
||||
) {
|
||||
@Transient
|
||||
private val createdAt: Date = Date()
|
||||
@@ -3529,35 +3955,51 @@ data class ServerCfg(
|
||||
get() = server.isBlank()
|
||||
|
||||
companion object {
|
||||
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false)
|
||||
val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false)
|
||||
|
||||
class SampleData(
|
||||
val preset: ServerCfg,
|
||||
val custom: ServerCfg,
|
||||
val untested: ServerCfg
|
||||
val preset: UserServer,
|
||||
val custom: UserServer,
|
||||
val untested: UserServer,
|
||||
val xftpPreset: UserServer
|
||||
)
|
||||
|
||||
val sampleData = SampleData(
|
||||
preset = ServerCfg(
|
||||
preset = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 1,
|
||||
server = "smp://abcd@smp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
deleted = false
|
||||
),
|
||||
custom = ServerCfg(
|
||||
custom = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 2,
|
||||
server = "smp://abcd@smp9.simplex.im",
|
||||
preset = false,
|
||||
tested = false,
|
||||
enabled = false
|
||||
enabled = false,
|
||||
deleted = false
|
||||
),
|
||||
untested = ServerCfg(
|
||||
untested = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 3,
|
||||
server = "smp://abcd@smp10.simplex.im",
|
||||
preset = false,
|
||||
tested = null,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
deleted = false
|
||||
),
|
||||
xftpPreset = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 4,
|
||||
server = "xftp://abcd@xftp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true,
|
||||
deleted = false
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -3671,7 +4113,7 @@ data class NetCfg(
|
||||
val hostMode: HostMode = HostMode.OnionViaSocks,
|
||||
val requiredHostMode: Boolean = false,
|
||||
val sessionMode: TransportSessionMode = TransportSessionMode.default,
|
||||
val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown,
|
||||
val smpProxyMode: SMPProxyMode = SMPProxyMode.Always,
|
||||
val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected,
|
||||
val smpWebPort: Boolean = false,
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
@@ -4914,10 +5356,13 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
||||
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
|
||||
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
|
||||
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
|
||||
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
|
||||
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
|
||||
@Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List<UserOperatorServers>): CR()
|
||||
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>): CR()
|
||||
@Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR()
|
||||
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
|
||||
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
|
||||
@Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR()
|
||||
@@ -5096,8 +5541,11 @@ sealed class CR {
|
||||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is ApiChatItemInfo -> "chatItemInfo"
|
||||
is UserProtoServers -> "userProtoServers"
|
||||
is ServerTestResult -> "serverTestResult"
|
||||
is ServerOperatorConditions -> "serverOperatorConditions"
|
||||
is UserServers -> "userServers"
|
||||
is UserServersValidation -> "userServersValidation"
|
||||
is UsageConditions -> "usageConditions"
|
||||
is ChatItemTTL -> "chatItemTTL"
|
||||
is NetworkConfig -> "networkConfig"
|
||||
is ContactInfo -> "contactInfo"
|
||||
@@ -5264,10 +5712,13 @@ sealed class CR {
|
||||
is ChatRunning -> noDetails()
|
||||
is ChatStopped -> noDetails()
|
||||
is ApiChats -> withUser(user, json.encodeToString(chats))
|
||||
is ApiChat -> withUser(user, json.encodeToString(chat))
|
||||
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
|
||||
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
|
||||
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
|
||||
is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}")
|
||||
is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}")
|
||||
is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}"
|
||||
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
|
||||
is NetworkConfig -> json.encodeToString(networkConfig)
|
||||
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")
|
||||
|
||||
@@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
val user = chatController.apiGetActiveUser(null)
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
chatModel.currentUser.value = null
|
||||
@@ -138,7 +139,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
} else if (startChat().await()) {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
if (appPlatform.isAndroid) {
|
||||
OnboardingStage.Step4_SetNotificationsMode
|
||||
} else {
|
||||
OnboardingStage.OnboardingComplete
|
||||
}
|
||||
} else {
|
||||
savedOnboardingStage
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ abstract class NtfManager {
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel)
|
||||
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
@@ -25,6 +26,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
|
||||
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
|
||||
import chat.simplex.common.views.chatlist.NavigationBarBackground
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -125,11 +127,11 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
||||
derivedStateOf { chatModel.terminalItems.value.asReversed() }
|
||||
}
|
||||
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
|
||||
var autoScrollToBottom = rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
var autoScrollToBottom = listState.firstVisibleItemIndex <= 1
|
||||
launch {
|
||||
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||
.filter { autoScrollToBottom }
|
||||
.filter { autoScrollToBottom.value }
|
||||
.collect {
|
||||
try {
|
||||
listState.scrollToItem(0)
|
||||
@@ -138,10 +140,16 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var oldNumberOfElements = listState.layoutInfo.totalItemsCount
|
||||
launch {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect {
|
||||
autoScrollToBottom = it == 0
|
||||
if (oldNumberOfElements != listState.layoutInfo.totalItemsCount) {
|
||||
oldNumberOfElements = listState.layoutInfo.totalItemsCount
|
||||
return@collect
|
||||
}
|
||||
autoScrollToBottom.value = it == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
|
||||
if (!chatModel.connectedToRemote()) {
|
||||
chatModel.localUserCreated.value = true
|
||||
}
|
||||
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
|
||||
controller.startChat(user)
|
||||
controller.switchUIRemoteHost(null)
|
||||
close()
|
||||
@@ -181,7 +181,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: ()
|
||||
chatModel.currentUser.value = user
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
|
||||
} else {
|
||||
val users = chatModel.controller.listUsers(rhId)
|
||||
chatModel.users.clear()
|
||||
@@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
|
||||
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
|
||||
OnboardingStage.Step2_5_SetupDatabasePassphrase
|
||||
} else {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
OnboardingStage.Step3_ChooseServerOperators
|
||||
})
|
||||
} else {
|
||||
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
@@ -201,7 +202,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
SectionItemView(
|
||||
click = {
|
||||
withBGApi {
|
||||
openChat(chatRh, forwardedFromItem.chatInfo, chatModel)
|
||||
openChat(chatRh, forwardedFromItem.chatInfo)
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
},
|
||||
@@ -477,28 +478,33 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
|
||||
}
|
||||
}
|
||||
TabRow(
|
||||
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colors.primary,
|
||||
) {
|
||||
availableTabs.forEach { ciInfoTab ->
|
||||
Tab(
|
||||
selected = selection.value::class == ciInfoTab::class,
|
||||
onClick = {
|
||||
selection.value = ciInfoTab
|
||||
},
|
||||
text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(tabIcon(ciInfoTab)),
|
||||
tabTitle(ciInfoTab)
|
||||
)
|
||||
},
|
||||
selectedContentColor = MaterialTheme.colors.primary,
|
||||
unselectedContentColor = MaterialTheme.colors.secondary,
|
||||
)
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(Modifier.offset(x = 0.dp, y = if (oneHandUI.value) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)) {
|
||||
TabRow(
|
||||
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
|
||||
Modifier.height(AppBarHeight * fontSizeSqrtMultiplier),
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
contentColor = MaterialTheme.colors.primary,
|
||||
) {
|
||||
availableTabs.forEach { ciInfoTab ->
|
||||
LeadingIconTab(
|
||||
selected = selection.value::class == ciInfoTab::class,
|
||||
onClick = {
|
||||
selection.value = ciInfoTab
|
||||
},
|
||||
text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(tabIcon(ciInfoTab)),
|
||||
tabTitle(ciInfoTab)
|
||||
)
|
||||
},
|
||||
selectedContentColor = MaterialTheme.colors.primary,
|
||||
unselectedContentColor = MaterialTheme.colors.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.min
|
||||
|
||||
const val TRIM_KEEP_COUNT = 200
|
||||
|
||||
suspend fun apiLoadMessages(
|
||||
rhId: Long?,
|
||||
chatType: ChatType,
|
||||
apiId: Long,
|
||||
pagination: ChatPagination,
|
||||
chatState: ActiveChatState,
|
||||
search: String = "",
|
||||
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
|
||||
) = coroutineScope {
|
||||
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope
|
||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|
||||
|| !isActive) return@coroutineScope
|
||||
|
||||
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
|
||||
val oldItems = chatModel.chatItems.value
|
||||
val newItems = SnapshotStateList<ChatItem>()
|
||||
when (pagination) {
|
||||
is ChatPagination.Initial -> {
|
||||
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
|
||||
withChats {
|
||||
if (chatModel.getChat(chat.id) == null) {
|
||||
addChat(chat)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
splits.value = newSplits
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
unreadAfterItemId.value = chat.chatItems.last().id
|
||||
}
|
||||
totalAfter.value = navInfo.afterTotal
|
||||
unreadTotal.value = chat.chatStats.unreadCount
|
||||
unreadAfter.value = navInfo.afterUnread
|
||||
unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
}
|
||||
}
|
||||
is ChatPagination.Before -> {
|
||||
newItems.addAll(oldItems)
|
||||
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
|
||||
if (indexInCurrentItems == -1) return@coroutineScope
|
||||
val (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
val wasSize = newItems.size
|
||||
val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed
|
||||
)
|
||||
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
|
||||
newItems.addAll(insertAt, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
}
|
||||
is ChatPagination.After -> {
|
||||
newItems.addAll(oldItems)
|
||||
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
|
||||
if (indexInCurrentItems == -1) return@coroutineScope
|
||||
|
||||
val mappedItems = mapItemsToIds(chat.chatItems)
|
||||
val newIds = mappedItems.first
|
||||
val (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
mappedItems.second, pagination.chatItemId, newItems, newIds, chat, splits
|
||||
)
|
||||
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
|
||||
val indexToAddIsLast = indexToAdd == newItems.size
|
||||
newItems.addAll(indexToAdd, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
if (indexToAddIsLast) {
|
||||
unreadAfterNewestLoaded.value -= unreadInLoaded
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatPagination.Around -> {
|
||||
newItems.addAll(oldItems)
|
||||
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.addAll(0, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
splits.value = listOf(chat.chatItems.last().id) + newSplits
|
||||
unreadAfterItemId.value = chat.chatItems.last().id
|
||||
totalAfter.value = navInfo.afterTotal
|
||||
unreadTotal.value = chat.chatStats.unreadCount
|
||||
unreadAfter.value = navInfo.afterUnread
|
||||
// no need to set it, count will be wrong
|
||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
}
|
||||
}
|
||||
is ChatPagination.Last -> {
|
||||
newItems.addAll(oldItems)
|
||||
removeDuplicates(newItems, chat)
|
||||
newItems.addAll(chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
unreadAfterNewestLoaded.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ModifiedSplits (
|
||||
val oldUnreadSplitIndex: Int,
|
||||
val newUnreadSplitIndex: Int,
|
||||
val trimmedIds: Set<Long>,
|
||||
val newSplits: List<Long>,
|
||||
)
|
||||
|
||||
private fun removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
unreadAfterItemId: StateFlow<Long>,
|
||||
newItems: SnapshotStateList<ChatItem>,
|
||||
newIds: Set<Long>,
|
||||
splits: StateFlow<List<Long>>,
|
||||
visibleItemIndexesNonReversed: () -> IntRange
|
||||
): ModifiedSplits {
|
||||
var oldUnreadSplitIndex: Int = -1
|
||||
var newUnreadSplitIndex: Int = -1
|
||||
val visibleItemIndexes = visibleItemIndexesNonReversed()
|
||||
var lastSplitIndexTrimmed = -1
|
||||
var allowedTrimming = true
|
||||
var index = 0
|
||||
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
|
||||
val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT
|
||||
val trimmedIds = mutableSetOf<Long>()
|
||||
val prevItemTrimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT + 1 .. newItems.size - TRIM_KEEP_COUNT
|
||||
var newSplits = splits.value
|
||||
|
||||
newItems.removeAll {
|
||||
val invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
|
||||
val prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
|
||||
// may disable it after clearing the whole split range
|
||||
if (splits.value.isNotEmpty() && it.id == splits.value.firstOrNull()) {
|
||||
// trim only in one split range
|
||||
allowedTrimming = false
|
||||
}
|
||||
val indexInSplits = splits.value.indexOf(it.id)
|
||||
if (indexInSplits != -1) {
|
||||
lastSplitIndexTrimmed = indexInSplits
|
||||
}
|
||||
if (invisibleItemToTrim) {
|
||||
if (prevItemWasTrimmed) {
|
||||
trimmedIds.add(it.id)
|
||||
} else {
|
||||
newUnreadSplitIndex = index
|
||||
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
|
||||
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
|
||||
if (lastSplitIndexTrimmed == -1) {
|
||||
newSplits = listOf(it.id) + newSplits
|
||||
} else {
|
||||
val new = ArrayList(newSplits)
|
||||
new[lastSplitIndexTrimmed] = it.id
|
||||
newSplits = new
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unreadAfterItemId.value == it.id) {
|
||||
oldUnreadSplitIndex = index
|
||||
}
|
||||
index++
|
||||
(invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains(it.id)
|
||||
}
|
||||
// will remove any splits that now becomes obsolete because items were merged
|
||||
newSplits = newSplits.filterNot { split -> newIds.contains(split) || trimmedIds.contains(split) }
|
||||
return ModifiedSplits(oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits)
|
||||
}
|
||||
|
||||
private fun removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
unreadInLoaded: Int,
|
||||
paginationChatItemId: Long,
|
||||
newItems: SnapshotStateList<ChatItem>,
|
||||
newIds: Set<Long>,
|
||||
chat: Chat,
|
||||
splits: StateFlow<List<Long>>
|
||||
): Pair<List<Long>, Int> {
|
||||
var unreadInLoaded = unreadInLoaded
|
||||
var firstItemIdBelowAllSplits: Long? = null
|
||||
val splitsToRemove = ArrayList<Long>()
|
||||
val indexInSplitRanges = splits.value.indexOf(paginationChatItemId)
|
||||
// Currently, it should always load from split range
|
||||
val loadingFromSplitRange = indexInSplitRanges != -1
|
||||
val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList()
|
||||
newItems.removeAll {
|
||||
val duplicate = newIds.contains(it.id)
|
||||
if (loadingFromSplitRange && duplicate) {
|
||||
if (splitsToMerge.contains(it.id)) {
|
||||
splitsToMerge.remove(it.id)
|
||||
splitsToRemove.add(it.id)
|
||||
} else if (firstItemIdBelowAllSplits == null && splitsToMerge.isEmpty()) {
|
||||
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
|
||||
firstItemIdBelowAllSplits = it.id
|
||||
}
|
||||
}
|
||||
if (duplicate && it.isRcvNew) {
|
||||
unreadInLoaded--
|
||||
}
|
||||
duplicate
|
||||
}
|
||||
var newSplits: List<Long> = emptyList()
|
||||
if (firstItemIdBelowAllSplits != null) {
|
||||
// no splits anymore, all were merged with bottom items
|
||||
newSplits = emptyList()
|
||||
} else {
|
||||
if (splitsToRemove.isNotEmpty()) {
|
||||
val new = ArrayList(splits.value)
|
||||
new.removeAll(splitsToRemove.toSet())
|
||||
newSplits = new
|
||||
}
|
||||
val enlargedSplit = splits.value.indexOf(paginationChatItemId)
|
||||
if (enlargedSplit != -1) {
|
||||
// move the split to the end of loaded items
|
||||
val new = ArrayList(splits.value)
|
||||
new[enlargedSplit] = chat.chatItems.last().id
|
||||
newSplits = new
|
||||
// Log.d(TAG, "Enlarged split range $newSplits")
|
||||
}
|
||||
}
|
||||
return newSplits to unreadInLoaded
|
||||
}
|
||||
|
||||
private fun removeDuplicatesAndUpperSplits(
|
||||
newItems: SnapshotStateList<ChatItem>,
|
||||
chat: Chat,
|
||||
splits: StateFlow<List<Long>>,
|
||||
visibleItemIndexesNonReversed: () -> IntRange
|
||||
): List<Long> {
|
||||
if (splits.value.isEmpty()) {
|
||||
removeDuplicates(newItems, chat)
|
||||
return splits.value
|
||||
}
|
||||
|
||||
val newSplits = splits.value.toMutableList()
|
||||
val visibleItemIndexes = visibleItemIndexesNonReversed()
|
||||
val (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
val idsToTrim = ArrayList<MutableSet<Long>>()
|
||||
idsToTrim.add(mutableSetOf())
|
||||
var index = 0
|
||||
newItems.removeAll {
|
||||
val duplicate = newIds.contains(it.id)
|
||||
if (!duplicate && visibleItemIndexes.first > index) {
|
||||
idsToTrim.last().add(it.id)
|
||||
}
|
||||
if (visibleItemIndexes.first > index && splits.value.contains(it.id)) {
|
||||
newSplits -= it.id
|
||||
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
|
||||
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
|
||||
idsToTrim.add(mutableSetOf())
|
||||
}
|
||||
|
||||
index++
|
||||
duplicate
|
||||
}
|
||||
if (idsToTrim.last().isNotEmpty()) {
|
||||
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
|
||||
// Otherwise, the last set would be empty
|
||||
idsToTrim.removeLast()
|
||||
}
|
||||
val allItemsToDelete = idsToTrim.flatten()
|
||||
if (allItemsToDelete.isNotEmpty()) {
|
||||
newItems.removeAll { allItemsToDelete.contains(it.id) }
|
||||
}
|
||||
return newSplits
|
||||
}
|
||||
|
||||
// ids, number of unread items
|
||||
private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> {
|
||||
var unreadInLoaded = 0
|
||||
val ids = mutableSetOf<Long>()
|
||||
var i = 0
|
||||
while (i < items.size) {
|
||||
val item = items[i]
|
||||
ids.add(item.id)
|
||||
if (item.isRcvNew) {
|
||||
unreadInLoaded++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return ids to unreadInLoaded
|
||||
}
|
||||
|
||||
private fun removeDuplicates(newItems: SnapshotStateList<ChatItem>, chat: Chat) {
|
||||
val (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll { newIds.contains(it.id) }
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class MergedItems (
|
||||
val items: List<MergedItem>,
|
||||
val splits: List<SplitRange>,
|
||||
// chat item id, index in list
|
||||
val indexInParentItems: Map<Long, Int>,
|
||||
) {
|
||||
companion object {
|
||||
fun create(items: List<ChatItem>, unreadCount: State<Int>, revealedItems: Set<Long>, chatState: ActiveChatState): MergedItems {
|
||||
if (items.isEmpty()) return MergedItems(emptyList(), emptyList(), emptyMap())
|
||||
|
||||
val unreadAfterItemId = chatState.unreadAfterItemId
|
||||
val itemSplits = chatState.splits.value
|
||||
val mergedItems = ArrayList<MergedItem>()
|
||||
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
|
||||
val splitRanges = ArrayList<SplitRange>()
|
||||
val indexInParentItems = mutableMapOf<Long, Int>()
|
||||
var index = 0
|
||||
var unclosedSplitIndex: Int? = null
|
||||
var unclosedSplitIndexInParent: Int? = null
|
||||
var visibleItemIndexInParent = -1
|
||||
var unreadBefore = unreadCount.value - chatState.unreadAfterNewestLoaded.value
|
||||
var lastRevealedIdsInMergedItems: MutableList<Long>? = null
|
||||
var lastRangeInReversedForMergedItems: MutableStateFlow<IntRange>? = null
|
||||
var recent: MergedItem? = null
|
||||
while (index < items.size) {
|
||||
val item = items[index]
|
||||
val prev = items.getOrNull(index - 1)
|
||||
val next = items.getOrNull(index + 1)
|
||||
val category = item.mergeCategory
|
||||
val itemIsSplit = itemSplits.contains(item.id)
|
||||
|
||||
if (item.id == unreadAfterItemId.value) {
|
||||
unreadBefore = unreadCount.value - chatState.unreadAfter.value
|
||||
}
|
||||
if (item.isRcvNew) unreadBefore--
|
||||
|
||||
val revealed = item.mergeCategory == null || revealedItems.contains(item.id)
|
||||
if (recent is MergedItem.Grouped && recent.mergeCategory == category && !revealedItems.contains(recent.items.first().item.id) && !itemIsSplit) {
|
||||
val listItem = ListItem(item, prev, next, unreadBefore)
|
||||
recent.items.add(listItem)
|
||||
|
||||
if (item.isRcvNew) {
|
||||
recent.unreadIds.add(item.id)
|
||||
}
|
||||
if (lastRevealedIdsInMergedItems != null && lastRangeInReversedForMergedItems != null) {
|
||||
if (revealed) {
|
||||
lastRevealedIdsInMergedItems += item.id
|
||||
}
|
||||
lastRangeInReversedForMergedItems.value = lastRangeInReversedForMergedItems.value.first..index
|
||||
}
|
||||
} else {
|
||||
visibleItemIndexInParent++
|
||||
val listItem = ListItem(item, prev, next, unreadBefore)
|
||||
recent = if (item.mergeCategory != null) {
|
||||
if (item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == null) {
|
||||
lastRevealedIdsInMergedItems = if (revealedItems.contains(item.id)) mutableListOf(item.id) else mutableListOf()
|
||||
} else if (revealed) {
|
||||
lastRevealedIdsInMergedItems += item.id
|
||||
}
|
||||
lastRangeInReversedForMergedItems = MutableStateFlow(index .. index)
|
||||
MergedItem.Grouped(
|
||||
items = arrayListOf(listItem),
|
||||
revealed = revealed,
|
||||
revealedIdsWithinGroup = lastRevealedIdsInMergedItems,
|
||||
rangeInReversed = lastRangeInReversedForMergedItems,
|
||||
mergeCategory = item.mergeCategory,
|
||||
startIndexInReversedItems = index,
|
||||
unreadIds = if (item.isRcvNew) mutableSetOf(item.id) else mutableSetOf()
|
||||
)
|
||||
} else {
|
||||
lastRangeInReversedForMergedItems = null
|
||||
MergedItem.Single(
|
||||
item = listItem,
|
||||
startIndexInReversedItems = index
|
||||
)
|
||||
}
|
||||
mergedItems.add(recent)
|
||||
}
|
||||
if (itemIsSplit) {
|
||||
// found item that is considered as a split
|
||||
if (unclosedSplitIndex != null && unclosedSplitIndexInParent != null) {
|
||||
// it was at least second split in the list
|
||||
splitRanges.add(SplitRange(unclosedSplitIndex until index, unclosedSplitIndexInParent until visibleItemIndexInParent))
|
||||
}
|
||||
unclosedSplitIndex = index
|
||||
unclosedSplitIndexInParent = visibleItemIndexInParent
|
||||
} else if (index + 1 == items.size && unclosedSplitIndex != null && unclosedSplitIndexInParent != null) {
|
||||
// just one split for the whole list, there will be no more, it's the end
|
||||
splitRanges.add(SplitRange(unclosedSplitIndex .. index, unclosedSplitIndexInParent .. visibleItemIndexInParent))
|
||||
}
|
||||
indexInParentItems[item.id] = visibleItemIndexInParent
|
||||
index++
|
||||
}
|
||||
return MergedItems(
|
||||
mergedItems,
|
||||
splitRanges,
|
||||
indexInParentItems
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MergedItem {
|
||||
abstract val startIndexInReversedItems: Int
|
||||
|
||||
// the item that is always single, cannot be grouped and always revealed
|
||||
data class Single(
|
||||
val item: ListItem,
|
||||
override val startIndexInReversedItems: Int,
|
||||
): MergedItem()
|
||||
|
||||
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
|
||||
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
|
||||
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
|
||||
* visible rows in ChatView LazyColumn */
|
||||
@Stable
|
||||
data class Grouped (
|
||||
val items: ArrayList<ListItem>,
|
||||
val revealed: Boolean,
|
||||
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
|
||||
// it's the same list instance for all Grouped items within revealed group
|
||||
/** @see reveal */
|
||||
val revealedIdsWithinGroup: MutableList<Long>,
|
||||
val rangeInReversed: MutableStateFlow<IntRange>,
|
||||
val mergeCategory: CIMergeCategory?,
|
||||
val unreadIds: MutableSet<Long>,
|
||||
override val startIndexInReversedItems: Int,
|
||||
): MergedItem() {
|
||||
fun reveal(reveal: Boolean, revealedItems: MutableState<Set<Long>>) {
|
||||
val newRevealed = revealedItems.value.toMutableSet()
|
||||
var i = 0
|
||||
if (reveal) {
|
||||
while (i < items.size) {
|
||||
newRevealed.add(items[i].item.id)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
while (i < revealedIdsWithinGroup.size) {
|
||||
newRevealed.remove(revealedIdsWithinGroup[i])
|
||||
i++
|
||||
}
|
||||
revealedIdsWithinGroup.clear()
|
||||
}
|
||||
revealedItems.value = newRevealed
|
||||
}
|
||||
}
|
||||
|
||||
fun hasUnread(): Boolean = when (this) {
|
||||
is Single -> item.item.isRcvNew
|
||||
is Grouped -> unreadIds.isNotEmpty()
|
||||
}
|
||||
|
||||
fun newest(): ListItem = when (this) {
|
||||
is Single -> item
|
||||
is Grouped -> items.first()
|
||||
}
|
||||
|
||||
fun oldest(): ListItem = when (this) {
|
||||
is Single -> item
|
||||
is Grouped -> items.last()
|
||||
}
|
||||
|
||||
fun lastIndexInReversed(): Int = when (this) {
|
||||
is Single -> startIndexInReversedItems
|
||||
is Grouped -> startIndexInReversedItems + items.lastIndex
|
||||
}
|
||||
}
|
||||
|
||||
data class SplitRange(
|
||||
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
|
||||
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
|
||||
* (3, 4 indexes of the splitRange with the split itself at index 3)
|
||||
* */
|
||||
val indexRangeInReversed: IntRange,
|
||||
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
|
||||
val indexRangeInParentItems: IntRange
|
||||
)
|
||||
|
||||
data class ListItem(
|
||||
val item: ChatItem,
|
||||
val prevItem: ChatItem?,
|
||||
val nextItem: ChatItem?,
|
||||
// how many unread items before (older than) this one (excluding this one)
|
||||
val unreadBefore: Int
|
||||
)
|
||||
|
||||
data class ActiveChatState (
|
||||
val splits: MutableStateFlow<List<Long>> = MutableStateFlow(emptyList()),
|
||||
val unreadAfterItemId: MutableStateFlow<Long> = MutableStateFlow(-1L),
|
||||
// total items after unread after item (exclusive)
|
||||
val totalAfter: MutableStateFlow<Int> = MutableStateFlow(0),
|
||||
val unreadTotal: MutableStateFlow<Int> = MutableStateFlow(0),
|
||||
// exclusive
|
||||
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
|
||||
// exclusive
|
||||
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
|
||||
) {
|
||||
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
|
||||
toItemId ?: return
|
||||
val currentIndex = nonReversedItems.indexOfFirst { it.id == unreadAfterItemId.value }
|
||||
val newIndex = nonReversedItems.indexOfFirst { it.id == toItemId }
|
||||
if (currentIndex == -1 || newIndex == -1) return
|
||||
unreadAfterItemId.value = toItemId
|
||||
val unreadDiff = if (newIndex > currentIndex) {
|
||||
-nonReversedItems.subList(currentIndex + 1, newIndex + 1).count { it.isRcvNew }
|
||||
} else {
|
||||
nonReversedItems.subList(newIndex + 1, currentIndex + 1).count { it.isRcvNew }
|
||||
}
|
||||
unreadAfter.value += unreadDiff
|
||||
}
|
||||
|
||||
fun moveUnreadAfterItem(fromIndex: Int, toIndex: Int, nonReversedItems: List<ChatItem>) {
|
||||
if (fromIndex == -1 || toIndex == -1) return
|
||||
unreadAfterItemId.value = nonReversedItems[toIndex].id
|
||||
val unreadDiff = if (toIndex > fromIndex) {
|
||||
-nonReversedItems.subList(fromIndex + 1, toIndex + 1).count { it.isRcvNew }
|
||||
} else {
|
||||
nonReversedItems.subList(toIndex + 1, fromIndex + 1).count { it.isRcvNew }
|
||||
}
|
||||
unreadAfter.value += unreadDiff
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
splits.value = emptyList()
|
||||
unreadAfterItemId.value = -1L
|
||||
totalAfter.value = 0
|
||||
unreadTotal.value = 0
|
||||
unreadAfter.value = 0
|
||||
unreadAfterNewestLoaded.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, listState: LazyListState): IntRange {
|
||||
val zero = 0 .. 0
|
||||
if (listState.layoutInfo.totalItemsCount == 0) return zero
|
||||
val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems
|
||||
val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed()
|
||||
if (newest == null || oldest == null) return zero
|
||||
val size = chatModel.chatItems.value.size
|
||||
val range = size - oldest .. size - newest
|
||||
if (range.first < 0 || range.last < 0) return zero
|
||||
|
||||
// visible items mapped to their underlying data structure which is chatModel.chatItems
|
||||
return range
|
||||
}
|
||||
|
||||
fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItemsChangesListener {
|
||||
override fun read(itemIds: Set<Long>?, newItems: List<ChatItem>) {
|
||||
val (_, unreadAfterItemId, _, unreadTotal, unreadAfter) = chatState
|
||||
if (itemIds == null) {
|
||||
// special case when the whole chat became read
|
||||
unreadTotal.value = 0
|
||||
unreadAfter.value = 0
|
||||
return
|
||||
}
|
||||
var unreadAfterItemIndex: Int = -1
|
||||
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
|
||||
var i = newItems.lastIndex
|
||||
val ids = itemIds.toMutableSet()
|
||||
// intermediate variables to prevent re-setting state value a lot of times without reason
|
||||
var newUnreadTotal = unreadTotal.value
|
||||
var newUnreadAfter = unreadAfter.value
|
||||
while (i >= 0) {
|
||||
val item = newItems[i]
|
||||
if (item.id == unreadAfterItemId.value) {
|
||||
unreadAfterItemIndex = i
|
||||
}
|
||||
if (ids.contains(item.id)) {
|
||||
// was unread, now this item is read
|
||||
if (unreadAfterItemIndex == -1) {
|
||||
newUnreadAfter--
|
||||
}
|
||||
newUnreadTotal--
|
||||
ids.remove(item.id)
|
||||
if (ids.isEmpty()) break
|
||||
}
|
||||
i--
|
||||
}
|
||||
unreadTotal.value = newUnreadTotal
|
||||
unreadAfter.value = newUnreadAfter
|
||||
}
|
||||
override fun added(item: Pair<Long, Boolean>, index: Int) {
|
||||
if (item.second) {
|
||||
chatState.unreadAfter.value++
|
||||
chatState.unreadTotal.value++
|
||||
}
|
||||
}
|
||||
override fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>) {
|
||||
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState
|
||||
val newSplits = ArrayList<Long>()
|
||||
for (split in splits.value) {
|
||||
val index = itemIds.indexOfFirst { it.first == split }
|
||||
// deleted the item that was right before the split between items, find newer item so it will act like the split
|
||||
if (index != -1) {
|
||||
val newSplit = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id
|
||||
// it the whole section is gone and splits overlap, don't add it at all
|
||||
if (newSplit != null && !newSplits.contains(newSplit)) {
|
||||
newSplits.add(newSplit)
|
||||
}
|
||||
} else {
|
||||
newSplits.add(split)
|
||||
}
|
||||
}
|
||||
splits.value = newSplits
|
||||
|
||||
val index = itemIds.indexOfFirst { it.first == unreadAfterItemId.value }
|
||||
// unread after item was removed
|
||||
if (index != -1) {
|
||||
var newUnreadAfterItemId = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id
|
||||
val newUnreadAfterItemWasNull = newUnreadAfterItemId == null
|
||||
if (newUnreadAfterItemId == null) {
|
||||
// everything on top (including unread after item) were deleted, take top item as unread after id
|
||||
newUnreadAfterItemId = newItems.firstOrNull()?.id
|
||||
}
|
||||
if (newUnreadAfterItemId != null) {
|
||||
unreadAfterItemId.value = newUnreadAfterItemId
|
||||
totalAfter.value -= itemIds.count { it.second > index }
|
||||
unreadTotal.value -= itemIds.count { it.second <= index && it.third }
|
||||
unreadAfter.value -= itemIds.count { it.second > index && it.third }
|
||||
if (newUnreadAfterItemWasNull) {
|
||||
// since the unread after item was moved one item after initial position, adjust counters accordingly
|
||||
if (newItems.firstOrNull()?.isRcvNew == true) {
|
||||
unreadTotal.value++
|
||||
unreadAfter.value--
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// all items were deleted, 0 items in chatItems
|
||||
unreadAfterItemId.value = -1L
|
||||
totalAfter.value = 0
|
||||
unreadTotal.value = 0
|
||||
unreadAfter.value = 0
|
||||
}
|
||||
} else {
|
||||
totalAfter.value -= itemIds.size
|
||||
}
|
||||
}
|
||||
override fun cleared() { chatState.clear() }
|
||||
}
|
||||
|
||||
/** Helps in debugging */
|
||||
//@Composable
|
||||
//fun BoxScope.ShowChatState() {
|
||||
// Box(Modifier.align(Alignment.Center).size(200.dp).background(Color.Black)) {
|
||||
// val s = chatModel.chatState
|
||||
// Text(
|
||||
// "itemId ${s.unreadAfterItemId.value} / ${chatModel.chatItems.value.firstOrNull { it.id == s.unreadAfterItemId.value }?.text}, \nunreadAfter ${s.unreadAfter.value}, afterNewest ${s.unreadAfterNewestLoaded.value}",
|
||||
// color = Color.White
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
//// Returns items mapping for easy checking the structure
|
||||
//fun MergedItems.mappingToString(): String = items.mapIndexed { index, g ->
|
||||
// when (g) {
|
||||
// is MergedItem.Single ->
|
||||
// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " +
|
||||
// "revealed true, " +
|
||||
// "mergeCategory null " +
|
||||
// "\nunreadBefore ${g.item.unreadBefore}"
|
||||
//
|
||||
// is MergedItem.Grouped ->
|
||||
// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " +
|
||||
// "revealed ${g.revealed}, " +
|
||||
// "mergeCategory ${g.items[0].item.mergeCategory} " +
|
||||
// g.items.mapIndexed { i, it ->
|
||||
// "\nunreadBefore ${it.unreadBefore} ${Triple(index, g.startIndexInReversedItems + i, it.item.id)}"
|
||||
// }
|
||||
// }
|
||||
//}.toString()
|
||||
@@ -429,8 +429,8 @@ fun ComposeView(
|
||||
ttl = ttl
|
||||
)
|
||||
|
||||
chatItems?.forEach { chatItem ->
|
||||
withChats {
|
||||
withChats {
|
||||
chatItems?.forEach { chatItem ->
|
||||
addChatItem(rhId, chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,17 +70,9 @@ fun GroupMemberInfoView(
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
openDirectChat = {
|
||||
withBGApi {
|
||||
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
withChats {
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
addChat(c)
|
||||
}
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
|
||||
if (chatModel.getContactChat(it) != null) {
|
||||
closeAll()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -92,7 +84,7 @@ fun GroupMemberInfoView(
|
||||
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
|
||||
withChats {
|
||||
addChat(memberChat)
|
||||
openLoadedChat(memberChat, chatModel)
|
||||
openLoadedChat(memberChat)
|
||||
}
|
||||
closeAll()
|
||||
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
|
||||
|
||||
@@ -21,7 +21,7 @@ fun CIChatFeatureView(
|
||||
feature: Feature,
|
||||
iconColor: Color,
|
||||
icon: Painter? = null,
|
||||
revealed: MutableState<Boolean>,
|
||||
revealed: State<Boolean>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
) {
|
||||
val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList()
|
||||
|
||||
@@ -431,6 +431,9 @@ fun VideoPreviewImageViewFullScreen(preview: ImageBitmap, onClick: () -> Unit, o
|
||||
@Composable
|
||||
expect fun LocalWindowWidth(): Dp
|
||||
|
||||
@Composable
|
||||
expect fun LocalWindowHeight(): Dp
|
||||
|
||||
@Composable
|
||||
private fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
|
||||
@@ -2,6 +2,8 @@ package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
@@ -56,8 +58,9 @@ fun ChatItemView(
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
revealed: MutableState<Boolean>,
|
||||
range: IntRange?,
|
||||
revealed: State<Boolean>,
|
||||
highlighted: State<Boolean>,
|
||||
range: State<IntRange?>,
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
fillMaxWidth: Boolean = true,
|
||||
selectChatItem: () -> Unit,
|
||||
@@ -79,6 +82,7 @@ fun ChatItemView(
|
||||
findModelMember: (String) -> GroupMember?,
|
||||
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
reveal: (Boolean) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
@@ -91,7 +95,7 @@ fun ChatItemView(
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = composeState.value.liveMessage != null
|
||||
val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value
|
||||
|
||||
Box(
|
||||
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier,
|
||||
@@ -134,10 +138,19 @@ fun ChatItemView(
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val enterInteraction = remember { HoverInteraction.Enter() }
|
||||
KeyChangeEffect(highlighted.value) {
|
||||
if (highlighted.value) {
|
||||
interactionSource.emit(enterInteraction)
|
||||
} else {
|
||||
interactionSource.emit(HoverInteraction.Exit(enterInteraction))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.clipChatItem(cItem, itemSeparation.largeGap, revealed.value)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current)
|
||||
.onRightClick { showMenu.value = true },
|
||||
) {
|
||||
@Composable
|
||||
@@ -275,7 +288,7 @@ fun ChatItemView(
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
if (revealed.value) {
|
||||
HideItemAction(revealed, showMenu)
|
||||
HideItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||
@@ -296,11 +309,11 @@ fun ChatItemView(
|
||||
cItem.meta.itemDeleted != null -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (revealed.value) {
|
||||
HideItemAction(revealed, showMenu)
|
||||
HideItemAction(revealed, showMenu, reveal)
|
||||
} else if (!cItem.isDeletedContent) {
|
||||
RevealItemAction(revealed, showMenu)
|
||||
} else if (range != null) {
|
||||
ExpandItemAction(revealed, showMenu)
|
||||
RevealItemAction(revealed, showMenu, reveal)
|
||||
} else if (range.value != null) {
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
@@ -320,12 +333,12 @@ fun ChatItemView(
|
||||
}
|
||||
}
|
||||
}
|
||||
cItem.mergeCategory != null && ((range?.count() ?: 0) > 1 || revealed.value) -> {
|
||||
cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (revealed.value) {
|
||||
ShrinkItemAction(revealed, showMenu)
|
||||
ShrinkItemAction(revealed, showMenu, reveal)
|
||||
} else {
|
||||
ExpandItemAction(revealed, showMenu)
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
@@ -350,7 +363,7 @@ fun ChatItemView(
|
||||
fun MarkedDeletedItemDropdownMenu() {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (!cItem.isDeletedContent) {
|
||||
RevealItemAction(revealed, showMenu)
|
||||
RevealItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
@@ -623,7 +636,7 @@ fun ItemInfoAction(
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
revealed: MutableState<Boolean>,
|
||||
revealed: State<Boolean>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
@@ -700,48 +713,48 @@ fun SelectItemAction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
|
||||
private fun RevealItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.reveal_verb),
|
||||
painterResource(MR.images.ic_visibility),
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
reveal(true)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HideItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
|
||||
private fun HideItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.hide_verb),
|
||||
painterResource(MR.images.ic_visibility_off),
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
reveal(false)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
|
||||
private fun ExpandItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.expand_verb),
|
||||
painterResource(MR.images.ic_expand_all),
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
reveal(true)
|
||||
showMenu.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShrinkItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
|
||||
private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.hide_verb),
|
||||
painterResource(MR.images.ic_collapse_all),
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
reveal(false)
|
||||
showMenu.value = false
|
||||
},
|
||||
)
|
||||
@@ -1063,7 +1076,8 @@ fun PreviewChatItemView(
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
revealed = remember { mutableStateOf(false) },
|
||||
range = 0..1,
|
||||
highlighted = remember { mutableStateOf(false) },
|
||||
range = remember { mutableStateOf(0..1) },
|
||||
selectedChatItems = remember { mutableStateOf(setOf()) },
|
||||
selectChatItem = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
@@ -1084,6 +1098,7 @@ fun PreviewChatItemView(
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
reveal = {},
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
showTimestamp = true,
|
||||
@@ -1104,7 +1119,8 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
revealed = remember { mutableStateOf(false) },
|
||||
range = 0..1,
|
||||
highlighted = remember { mutableStateOf(false) },
|
||||
range = remember { mutableStateOf(0..1) },
|
||||
selectedChatItems = remember { mutableStateOf(setOf()) },
|
||||
selectChatItem = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
@@ -1125,6 +1141,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
reveal = {},
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
preview = true,
|
||||
|
||||
@@ -129,7 +129,14 @@ fun FramedItemView(
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
||||
onClick = {
|
||||
val itemId = qi.itemId
|
||||
if (itemId != null) {
|
||||
scrollToItem(itemId)
|
||||
} else {
|
||||
showQuotedItemDoesNotExistAlert()
|
||||
}
|
||||
}
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
@@ -465,6 +472,13 @@ fun CenteredRowLayout(
|
||||
}
|
||||
}
|
||||
|
||||
fun showQuotedItemDoesNotExistAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.message_deleted_or_not_received_error_title),
|
||||
text = generalGetString(MR.strings.message_deleted_or_not_received_error_desc)
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
|
||||
@@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
Surface(
|
||||
@@ -41,7 +41,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState<Boolean>) {
|
||||
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
|
||||
var i = getChatItemIndexOrNull(chatItem)
|
||||
val ciCategory = chatItem.mergeCategory
|
||||
val text = if (!revealed.value && ciCategory != null && i != null) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -33,6 +33,7 @@ import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
|
||||
@@ -71,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
|
||||
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false)
|
||||
}
|
||||
},
|
||||
click = { scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } },
|
||||
click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } },
|
||||
dropdownMenuItems = {
|
||||
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
|
||||
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead)
|
||||
@@ -90,7 +91,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
|
||||
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout)
|
||||
}
|
||||
},
|
||||
click = { if (!inProgress.value) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } },
|
||||
click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } },
|
||||
dropdownMenuItems = {
|
||||
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
|
||||
GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead)
|
||||
@@ -108,7 +109,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
|
||||
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false)
|
||||
}
|
||||
},
|
||||
click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
|
||||
click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
|
||||
dropdownMenuItems = {
|
||||
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
|
||||
NoteFolderMenuItems(chat, showMenu, showMarkRead)
|
||||
@@ -187,7 +188,7 @@ fun ErrorChatListItem() {
|
||||
suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) {
|
||||
when {
|
||||
contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true)
|
||||
else -> openChat(rhId, ChatInfo.Direct(contact), chatModel)
|
||||
else -> openChat(rhId, ChatInfo.Direct(contact))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,54 +196,31 @@ suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
|
||||
when (groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress)
|
||||
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId)
|
||||
else -> openChat(rhId, ChatInfo.Group(groupInfo), chatModel)
|
||||
else -> openChat(rhId, ChatInfo.Group(groupInfo))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) {
|
||||
openChat(rhId, ChatInfo.Local(noteFolder), chatModel)
|
||||
}
|
||||
suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(rhId, ChatInfo.Local(noteFolder))
|
||||
|
||||
suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId)
|
||||
|
||||
suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId)
|
||||
|
||||
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId)
|
||||
|
||||
fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
|
||||
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) =
|
||||
apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
|
||||
|
||||
fun openLoadedChat(chat: Chat) {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
chatModel.chatState.clear()
|
||||
}
|
||||
|
||||
suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
|
||||
val chatInfo = ch.chatInfo
|
||||
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
if (chatModel.chatId.value != chat.id) return
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
|
||||
val chatInfo = ch.chatInfo
|
||||
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||
if (chatModel.chatId.value != chat.id) return
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
suspend fun apiFindMessages(ch: Chat, search: String) {
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search)
|
||||
}
|
||||
|
||||
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
@@ -724,7 +702,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
|
||||
close?.invoke()
|
||||
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false)
|
||||
if (ok && openChat) {
|
||||
openDirectChat(rhId, contact.contactId, chatModel)
|
||||
openDirectChat(rhId, contact.contactId)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
@@ -736,7 +714,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
|
||||
close?.invoke()
|
||||
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true)
|
||||
if (ok && openChat) {
|
||||
openDirectChat(rhId, contact.contactId, chatModel)
|
||||
openDirectChat(rhId, contact.contactId)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -25,17 +25,21 @@ import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.setConditionsNotified
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.chat.item.CIFileViewScope
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
import chat.simplex.common.views.mkValidName
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.views.showInvalidNameAlert
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -72,58 +76,39 @@ private fun showNewChatSheet(oneHandUI: State<Boolean>) {
|
||||
|
||||
@Composable
|
||||
fun ToggleChatListCard() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
ChatListCard(
|
||||
close = {
|
||||
appPrefs.oneHandUICardShown.set(true)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.one_hand_ui),
|
||||
text = generalGetString(MR.strings.one_hand_ui_change_instruction),
|
||||
)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.appColors.sentMessage)
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
.padding(top = DEFAULT_PADDING)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().matchParentSize().padding(5.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
appPrefs.oneHandUICardShown.set(true)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.one_hand_ui),
|
||||
text = generalGetString(MR.strings.one_hand_ui_change_instruction),
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close), stringResource(MR.strings.back), tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
Text(stringResource(MR.strings.one_hand_ui_card_title), style = MaterialTheme.typography.h3)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
.padding(top = DEFAULT_PADDING)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(MR.strings.one_hand_ui_card_title), style = MaterialTheme.typography.h3)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(MR.strings.one_hand_ui), Modifier.weight(10f), style = MaterialTheme.typography.body1)
|
||||
Text(stringResource(MR.strings.one_hand_ui), Modifier.weight(10f), style = MaterialTheme.typography.body1)
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
SharedPreferenceToggle(
|
||||
appPrefs.oneHandUI,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
SharedPreferenceToggle(
|
||||
appPrefs.oneHandUI,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,10 +117,26 @@ fun ToggleChatListCard() {
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val rhId = chatModel.remoteHostId()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
val showWhatsNew = shouldShowWhatsNew(chatModel)
|
||||
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
|
||||
if (showWhatsNew) {
|
||||
delay(1000L)
|
||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
|
||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
|
||||
} else if (showUpdatedConditions) {
|
||||
ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
try {
|
||||
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}")
|
||||
}
|
||||
}
|
||||
UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +197,94 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListCard(
|
||||
close: () -> Unit,
|
||||
onCardClick: (() -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.clip(RoundedCornerShape(18.dp))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.appColors.sentMessage)
|
||||
.clickable {
|
||||
onCardClick?.invoke()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().matchParentSize().padding(5.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
close()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close), stringResource(MR.strings.back), tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressCreationCard() {
|
||||
ChatListCard(
|
||||
close = {
|
||||
appPrefs.addressCreationCardShown.set(true)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.simplex_address),
|
||||
text = generalGetString(MR.strings.address_creation_instruction),
|
||||
)
|
||||
},
|
||||
onCardClick = {
|
||||
ModalManager.start.showModal {
|
||||
UserAddressLearnMore(showCreateAddressButton = true)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.matchParentSize().padding(end = (DEFAULT_PADDING_HALF + 2.dp) * fontSizeSqrtMultiplier, bottom = 2.dp), contentAlignment = Alignment.BottomEnd) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(MR.strings.create_address_button), style = MaterialTheme.typography.body1)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 4.dp)) {
|
||||
Box(Modifier.background(MaterialTheme.colors.primary, CircleShape).padding(12.dp)) {
|
||||
ProfileImage(size = 37.dp, null, icon = MR.images.ic_mail_filled, color = Color.White, backgroundColor = Color.Red)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = DEFAULT_PADDING)) {
|
||||
Text(stringResource(MR.strings.your_simplex_contact_address), style = MaterialTheme.typography.h3)
|
||||
Spacer(Modifier.fillMaxWidth().padding(DEFAULT_PADDING_HALF))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(MR.strings.how_to_use_simplex_chat), Modifier.padding(end = DEFAULT_SPACE_AFTER_ICON), style = MaterialTheme.typography.body1)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_info),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
@@ -641,6 +730,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
val keyboardState by getKeyboardState()
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
|
||||
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
|
||||
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
val currentIndex = listState.firstVisibleItemIndex
|
||||
@@ -709,20 +799,15 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!oneHandUICardShown.value && chats.size > 1) {
|
||||
item {
|
||||
ToggleChatListCard()
|
||||
}
|
||||
}
|
||||
itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat ->
|
||||
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
|
||||
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
|
||||
} }
|
||||
ChatListNavLinkView(chat, nextChatSelected)
|
||||
}
|
||||
if (!oneHandUICardShown.value && chats.size <= 1) {
|
||||
if (!oneHandUICardShown.value || !addressCreationCardShown.value) {
|
||||
item {
|
||||
ToggleChatListCard()
|
||||
ChatListFeatureCards()
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
@@ -741,7 +826,36 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
}
|
||||
if (!oneHandUICardShown.value) {
|
||||
LaunchedEffect(chats.size) {
|
||||
if (chats.size >= 3) appPrefs.oneHandUICardShown.set(true)
|
||||
if (chats.size >= 3) {
|
||||
appPrefs.oneHandUICardShown.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!addressCreationCardShown.value) {
|
||||
LaunchedEffect(chatModel.userAddress.value) {
|
||||
if (chatModel.userAddress.value != null) {
|
||||
appPrefs.addressCreationCardShown.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListFeatureCards() {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
|
||||
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (!oneHandUICardShown.value && !oneHandUI.value) {
|
||||
ToggleChatListCard()
|
||||
}
|
||||
if (!addressCreationCardShown.value) {
|
||||
AddressCreationCard()
|
||||
}
|
||||
if (!oneHandUICardShown.value && oneHandUI.value) {
|
||||
ToggleChatListCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ fun ChatPreviewView(
|
||||
fun chatItemContentPreview(chat: Chat, ci: ChatItem?) {
|
||||
val mc = ci?.content?.msgContent
|
||||
val provider by remember(chat.id, ci?.id, ci?.file?.fileStatus) {
|
||||
mutableStateOf({ providerForGallery(0, chat.chatItems, ci?.id ?: 0) {} })
|
||||
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
when (mc) {
|
||||
|
||||
@@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.ProtocolServersView
|
||||
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (summary.known == true) {
|
||||
SectionItemView(click = {
|
||||
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) }
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.open_server_settings_button))
|
||||
}
|
||||
if (summary.stats != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
if (summary.stats != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (summary.stats != null) {
|
||||
@@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
|
||||
)
|
||||
)
|
||||
}
|
||||
if (summary.known == true) {
|
||||
SectionItemView(click = {
|
||||
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.open_server_settings_button))
|
||||
}
|
||||
if (summary.stats != null || summary.subs != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
|
||||
@@ -268,22 +268,32 @@ fun UserPicker(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
{
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
ModalView(
|
||||
{ close() },
|
||||
showSearch = true,
|
||||
searchAlwaysVisible = true,
|
||||
onSearchValueChanged = {
|
||||
search.value = it
|
||||
},
|
||||
content = { UserProfilesView(chatModel, search, profileHidden) })
|
||||
}
|
||||
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
val authorized = remember { stateGetOrPut("authorized") { false } }
|
||||
ModalView(
|
||||
{ close() },
|
||||
showSearch = true,
|
||||
searchAlwaysVisible = true,
|
||||
onSearchValueChanged = {
|
||||
search.value = it
|
||||
},
|
||||
content = {
|
||||
UserProfilesView(chatModel, search, profileHidden) { block ->
|
||||
if (authorized.value) {
|
||||
block()
|
||||
} else {
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
authorized.value = true
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
disabled = stopped
|
||||
@@ -412,26 +422,35 @@ fun UserProfilePickerItem(
|
||||
UserProfileRow(u, enabled)
|
||||
if (u.activeUser) {
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (u.hidden) {
|
||||
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else if (unreadCount > 0) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||
.padding(2.dp)
|
||||
.badgeLayout()
|
||||
)
|
||||
}
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (unreadCount > 0) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier
|
||||
.background(if (u.showNtfs) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
|
||||
.padding(2.dp)
|
||||
.badgeLayout()
|
||||
)
|
||||
}
|
||||
|
||||
if (u.hidden) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
} else if (u.hidden) {
|
||||
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fun onRequestAccepted(chat: Chat) {
|
||||
if (chatInfo is ChatInfo.Direct) {
|
||||
ModalManager.start.closeModals()
|
||||
if (chatInfo.contact.sndReady) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
openLoadedChat(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,13 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, showDel
|
||||
when (contactType) {
|
||||
ContactType.RECENT -> {
|
||||
withApi {
|
||||
openChat(rhId, chat.chatInfo, chatModel)
|
||||
openChat(rhId, chat.chatInfo)
|
||||
ModalManager.start.closeModals()
|
||||
}
|
||||
}
|
||||
ContactType.CHAT_DELETED -> {
|
||||
withApi {
|
||||
openChat(rhId, chat.chatInfo, chatModel)
|
||||
openChat(rhId, chat.chatInfo)
|
||||
ModalManager.start.closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +500,7 @@ fun deleteChatDatabaseFilesAndState() {
|
||||
|
||||
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withLongRunningApi {
|
||||
withChats {
|
||||
chats.clear()
|
||||
|
||||
@@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
|
||||
fun AppBarTitle(
|
||||
title: String,
|
||||
hostDevice: Pair<Long?, String>? = null,
|
||||
withPadding: Boolean = true,
|
||||
bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp,
|
||||
enableAlphaChanges: Boolean = true
|
||||
) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = handler?.connection
|
||||
val connection = if (enableAlphaChanges) handler?.connection else null
|
||||
LaunchedEffect(title) {
|
||||
handler?.title?.value = title
|
||||
if (enableAlphaChanges) {
|
||||
handler?.title?.value = title
|
||||
} else {
|
||||
handler?.connection?.scrollTrackingEnabled = false
|
||||
}
|
||||
}
|
||||
val theme = CurrentColors.collectAsState()
|
||||
val titleColor = MaterialTheme.appColors.title
|
||||
@@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPad
|
||||
}
|
||||
|
||||
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
|
||||
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
if (connection?.scrollTrackingEnabled == false) 1f
|
||||
else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -84,6 +84,7 @@ class AppBarHandler(
|
||||
}
|
||||
|
||||
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
|
||||
var scrollTrackingEnabled = true
|
||||
var appBarOffset: Float by mutableFloatStateOf(0f)
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
|
||||
@@ -258,7 +258,8 @@ private fun AppBarCenterAligned(
|
||||
}
|
||||
|
||||
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
|
||||
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
|
||||
if (!connection.scrollTrackingEnabled) 0f
|
||||
else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
|
||||
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
|
||||
|
||||
val AppBarHeight = 56.dp
|
||||
|
||||
@@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
|
||||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@@ -44,7 +44,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
|
||||
if (groupInfo != null) {
|
||||
withChats {
|
||||
updateGroup(rhId = rhId, groupInfo)
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co
|
||||
val c = chatModel.getContactChat(contact.contactId)
|
||||
if (c != null) {
|
||||
close?.invoke()
|
||||
openDirectChat(rhId, contact.contactId, chatModel)
|
||||
openDirectChat(rhId, contact.contactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,7 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou
|
||||
val g = chatModel.getGroupChat(groupInfo.groupId)
|
||||
if (g != null) {
|
||||
close?.invoke()
|
||||
openGroupChat(rhId, groupInfo.groupId, chatModel)
|
||||
openGroupChat(rhId, groupInfo.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionTextFooter
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ServerOperator
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun ModalData.ChooseServerOperators(
|
||||
onboarding: Boolean,
|
||||
close: (() -> Unit) = { ModalManager.fullscreen.closeModals() },
|
||||
modalManager: ModalManager = ModalManager.fullscreen
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
prepareChatBeforeFinishingOnboarding()
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
ModalView({}, showClose = false, endButtons = {
|
||||
IconButton({ modalManager.showModal { ChooseServerOperatorsInfoView() } }) {
|
||||
Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}) {
|
||||
val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } }
|
||||
val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } }
|
||||
val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer),
|
||||
maxIntrinsicSize = true
|
||||
) {
|
||||
Box(Modifier.align(Alignment.CenterHorizontally)) {
|
||||
AppBarTitle(stringResource(MR.strings.onboarding_choose_server_operators))
|
||||
}
|
||||
Column((
|
||||
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
Text(stringResource(MR.strings.onboarding_select_network_operators_to_use))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column((
|
||||
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
serverOperators.value.forEachIndexed { index, srvOperator ->
|
||||
OperatorCheckView(srvOperator, selectedOperatorIds)
|
||||
if (index != serverOperators.value.lastIndex) {
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.onboarding_network_operators_configure_via_settings), textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
|
||||
val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed }
|
||||
val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet()
|
||||
|
||||
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val enabled = selectedOperatorIds.value.isNotEmpty()
|
||||
when {
|
||||
reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager)
|
||||
selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close)
|
||||
else -> ContinueButton(enabled, onboarding, close)
|
||||
}
|
||||
if (onboarding && reviewForOperators.isEmpty()) {
|
||||
TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) {
|
||||
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
UsageConditionsView(
|
||||
currUserServers = remember { mutableStateOf(emptyList()) },
|
||||
userServers = remember { mutableStateOf(emptyList()) },
|
||||
close = close,
|
||||
rhId = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (onboarding || reviewForOperators.isEmpty()) {
|
||||
// Reserve space
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
if (!onboarding && reviewForOperators.isNotEmpty()) {
|
||||
ReviewLaterButton(canReviewLater, close)
|
||||
SectionTextFooter(
|
||||
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) +
|
||||
AnnotatedString(" ") +
|
||||
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorIds: MutableState<Set<Long>>) {
|
||||
val checked = selectedOperatorIds.value.contains(serverOperator.operatorId)
|
||||
TextButton({
|
||||
if (checked) {
|
||||
selectedOperatorIds.value -= serverOperator.operatorId
|
||||
} else {
|
||||
selectedOperatorIds.value += serverOperator.operatorId
|
||||
}
|
||||
},
|
||||
border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Row(Modifier.padding(DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
|
||||
Spacer(Modifier.width(DEFAULT_PADDING_HALF).weight(1f))
|
||||
CircleCheckbox(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircleCheckbox(checked: Boolean) {
|
||||
if (checked) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_circle_filled),
|
||||
null,
|
||||
Modifier.size(26.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_check_filled),
|
||||
null,
|
||||
Modifier.size(20.dp), tint = MaterialTheme.colors.background
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_circle),
|
||||
null,
|
||||
Modifier.size(26.dp),
|
||||
tint = MaterialTheme.colors.secondary.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewConditionsButton(
|
||||
enabled: Boolean,
|
||||
onboarding: Boolean,
|
||||
selectedOperators: State<List<ServerOperator>>,
|
||||
selectedOperatorIds: State<Set<Long>>,
|
||||
modalManager: ModalManager
|
||||
) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
|
||||
labelId = MR.strings.operator_review_conditions,
|
||||
onboarding = null,
|
||||
enabled = enabled,
|
||||
onclick = {
|
||||
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State<List<ServerOperator>>, selectedOperatorIds: State<Set<Long>>, close: () -> Unit) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
|
||||
labelId = MR.strings.onboarding_network_operators_update,
|
||||
onboarding = null,
|
||||
enabled = enabled,
|
||||
onclick = {
|
||||
withBGApi {
|
||||
val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value)
|
||||
if (enabledOperators != null) {
|
||||
val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
|
||||
if (r != null) {
|
||||
chatModel.conditions.value = r
|
||||
}
|
||||
continueToNextStep(onboarding, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
|
||||
labelId = MR.strings.onboarding_network_operators_continue,
|
||||
onboarding = null,
|
||||
enabled = enabled,
|
||||
onclick = {
|
||||
continueToNextStep(onboarding, close)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) {
|
||||
TextButtonBelowOnboardingButton(
|
||||
stringResource(MR.strings.onboarding_network_operators_review_later),
|
||||
onClick = if (!enabled) null else {{ continueToNextStep(false, close) }}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewConditionsView(
|
||||
onboarding: Boolean,
|
||||
selectedOperators: State<List<ServerOperator>>,
|
||||
selectedOperatorIds: State<Set<Long>>,
|
||||
close: () -> Unit
|
||||
) {
|
||||
// remembering both since we don't want to reload the view after the user accepts conditions
|
||||
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
|
||||
val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } }
|
||||
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false)
|
||||
if (operatorsWithConditionsAccepted.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ })
|
||||
ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ })
|
||||
} else {
|
||||
ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ })
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(chatModel.remoteHostId())
|
||||
}
|
||||
Column(Modifier.padding(top = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close)
|
||||
// Reserve space
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AcceptConditionsButton(
|
||||
onboarding: Boolean,
|
||||
selectedOperators: State<List<ServerOperator>>,
|
||||
selectedOperatorIds: State<Set<Long>>,
|
||||
close: () -> Unit
|
||||
) {
|
||||
fun continueOnAccept() {
|
||||
if (appPlatform.isDesktop || !onboarding) {
|
||||
if (onboarding) { close() }
|
||||
continueToNextStep(onboarding, close)
|
||||
} else {
|
||||
continueToSetNotificationsAfterAccept()
|
||||
}
|
||||
}
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
|
||||
labelId = MR.strings.accept_conditions,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
withBGApi {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
|
||||
val operatorIds = acceptForOperators.map { it.operatorId }
|
||||
val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds)
|
||||
if (r != null) {
|
||||
chatModel.conditions.value = r
|
||||
val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value)
|
||||
if (enabledOperators != null) {
|
||||
val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
|
||||
if (r2 != null) {
|
||||
chatModel.conditions.value = r2
|
||||
continueOnAccept()
|
||||
}
|
||||
} else {
|
||||
continueOnAccept()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) {
|
||||
if (onboarding) {
|
||||
appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun continueToSetNotificationsAfterAccept() {
|
||||
appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
|
||||
ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) }
|
||||
}
|
||||
|
||||
private fun enabledOperators(operators: List<ServerOperator>, selectedOperatorIds: Set<Long>): List<ServerOperator>? {
|
||||
val ops = ArrayList(operators)
|
||||
if (ops.isNotEmpty()) {
|
||||
for (i in ops.indices) {
|
||||
val op = ops[i]
|
||||
ops[i] = op.copy(enabled = selectedOperatorIds.contains(op.operatorId))
|
||||
}
|
||||
val haveSMPStorage = ops.any { it.enabled && it.smpRoles.storage }
|
||||
val haveSMPProxy = ops.any { it.enabled && it.smpRoles.proxy }
|
||||
val haveXFTPStorage = ops.any { it.enabled && it.xftpRoles.storage }
|
||||
val haveXFTPProxy = ops.any { it.enabled && it.xftpRoles.proxy }
|
||||
val firstEnabledIndex = ops.indexOfFirst { it.enabled }
|
||||
if (haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy) {
|
||||
return ops
|
||||
} else if (firstEnabledIndex != -1) {
|
||||
var op = ops[firstEnabledIndex]
|
||||
if (!haveSMPStorage) op = op.copy(smpRoles = op.smpRoles.copy(storage = true))
|
||||
if (!haveSMPProxy) op = op.copy(smpRoles = op.smpRoles.copy(proxy = true))
|
||||
if (!haveXFTPStorage) op = op.copy(xftpRoles = op.xftpRoles.copy(storage = true))
|
||||
if (!haveXFTPProxy) op = op.copy(xftpRoles = op.xftpRoles.copy(proxy = true))
|
||||
ops[firstEnabledIndex] = op
|
||||
return ops
|
||||
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseServerOperatorsInfoView() {
|
||||
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.onboarding_network_operators), withPadding = false)
|
||||
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_different_operators))
|
||||
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_for_routing))
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.SimpleXLinkQRCode
|
||||
import chat.simplex.common.views.newchat.simplexChatLink
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) {
|
||||
var progressIndicator by remember { mutableStateOf(false) }
|
||||
val userAddress = remember { m.userAddress }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
prepareChatBeforeAddressCreation(rhId)
|
||||
}
|
||||
|
||||
CreateSimpleXAddressLayout(
|
||||
userAddress.value,
|
||||
share = { address: String -> clipboard.shareText(address) },
|
||||
sendEmail = { address ->
|
||||
uriHandler.sendEmail(
|
||||
generalGetString(MR.strings.email_invite_subject),
|
||||
generalGetString(MR.strings.email_invite_body).format(simplexChatLink(address.connReqContact))
|
||||
)
|
||||
},
|
||||
createAddress = {
|
||||
withBGApi {
|
||||
progressIndicator = true
|
||||
val connReqContact = m.controller.apiCreateUserAddress(rhId)
|
||||
if (connReqContact != null) {
|
||||
m.userAddress.value = UserContactLinkRec(connReqContact)
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
},
|
||||
nextStep = {
|
||||
val next = if (appPlatform.isAndroid) {
|
||||
OnboardingStage.Step4_SetNotificationsMode
|
||||
} else {
|
||||
OnboardingStage.OnboardingComplete
|
||||
}
|
||||
m.controller.appPrefs.onboardingStage.set(next)
|
||||
},
|
||||
)
|
||||
|
||||
if (progressIndicator) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateSimpleXAddressLayout(
|
||||
userAddress: UserContactLinkRec?,
|
||||
share: (String) -> Unit,
|
||||
sendEmail: (UserContactLinkRec) -> Unit,
|
||||
createAddress: () -> Unit,
|
||||
nextStep: () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
ModalView({}, showClose = false) {
|
||||
ColumnWithScrollBar(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.simplex_address))
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (userAddress != null) {
|
||||
SimpleXLinkQRCode(userAddress.connReqContact)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
Row {
|
||||
ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) }
|
||||
Spacer(Modifier.width(DEFAULT_PADDING * 2))
|
||||
ShareViaEmailButton { sendEmail(userAddress) }
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.continue_to_next_step,
|
||||
onboarding = null,
|
||||
onclick = nextStep
|
||||
)
|
||||
// Reserve space
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
} else {
|
||||
Button(createAddress, Modifier, shape = CircleShape, contentPadding = PaddingValues()) {
|
||||
Icon(painterResource(MR.images.ic_mail_filled), null, Modifier.size(100.dp).background(MaterialTheme.colors.primary, CircleShape).padding(25.dp), tint = Color.White)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(stringResource(MR.strings.create_simplex_address), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold)
|
||||
TextBelowButton(stringResource(MR.strings.you_can_make_address_visible_via_settings))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.create_address_button,
|
||||
onboarding = null,
|
||||
onclick = createAddress
|
||||
)
|
||||
TextButtonBelowOnboardingButton(stringResource(MR.strings.dont_create_address), nextStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareAddressButton(onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
IconButton(onClick, Modifier.padding(bottom = DEFAULT_PADDING_HALF).border(1.dp, MaterialTheme.colors.secondary.copy(0.1f), CircleShape)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_share_filled), generalGetString(MR.strings.share_verb), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.size(50.dp).padding(DEFAULT_PADDING_HALF)
|
||||
)
|
||||
}
|
||||
Text(stringResource(MR.strings.share_verb))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareViaEmailButton(onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
IconButton(onClick, Modifier.padding(bottom = DEFAULT_PADDING_HALF).border(1.dp, MaterialTheme.colors.secondary.copy(0.1f), CircleShape)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_mail), generalGetString(MR.strings.share_verb), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.size(50.dp).padding(DEFAULT_PADDING_HALF)
|
||||
)
|
||||
}
|
||||
Text(stringResource(MR.strings.invite_friends_short))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextBelowButton(text: String) {
|
||||
Text(
|
||||
text,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING * 3, vertical = DEFAULT_PADDING_HALF),
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareChatBeforeAddressCreation(rhId: Long?) {
|
||||
// No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users
|
||||
if (chatModel.users.any { u -> !u.user.hidden }) return
|
||||
withBGApi {
|
||||
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withBGApi
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.controller.startChat(user)
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
|
||||
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) {
|
||||
Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,6 +5,7 @@ enum class OnboardingStage {
|
||||
Step2_CreateProfile,
|
||||
LinkAMobile,
|
||||
Step2_5_SetupDatabasePassphrase,
|
||||
Step3_ChooseServerOperators,
|
||||
Step3_CreateSimpleXAddress,
|
||||
Step4_SetNotificationsMode,
|
||||
OnboardingComplete
|
||||
|
||||
@@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.changeNotificationsMode
|
||||
@@ -25,6 +24,10 @@ import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun SetNotificationsMode(m: ChatModel) {
|
||||
LaunchedEffect(Unit) {
|
||||
prepareChatBeforeFinishingOnboarding()
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
ModalView({}, showClose = false) {
|
||||
ColumnWithScrollBar(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
|
||||
@@ -53,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
|
||||
onboarding = OnboardingStage.OnboardingComplete,
|
||||
onclick = {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
ModalManager.fullscreen.closeModals()
|
||||
}
|
||||
)
|
||||
// Reserve space
|
||||
@@ -94,3 +98,13 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
fun prepareChatBeforeFinishingOnboarding() {
|
||||
// No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users
|
||||
if (chatModel.users.any { u -> !u.user.hidden }) return
|
||||
withBGApi {
|
||||
val user = chatModel.controller.apiGetActiveUser(null) ?: return@withBGApi
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.controller.startChat(user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.database.*
|
||||
@@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
|
||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
fun nextStep() {
|
||||
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
|
||||
} else {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
|
||||
}
|
||||
|
||||
@@ -164,14 +164,15 @@ fun OnboardingActionButton(
|
||||
@Composable
|
||||
fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) {
|
||||
val state = getKeyboardState()
|
||||
val enabled = onClick != null
|
||||
val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING)
|
||||
val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2)
|
||||
if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) {
|
||||
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) {
|
||||
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp),
|
||||
color = MaterialTheme.colors.primary,
|
||||
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.setConditionsNotified
|
||||
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
|
||||
@Composable
|
||||
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) {
|
||||
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
|
||||
val rhId = chatModel.remoteHostId()
|
||||
|
||||
if (updatedConditions) {
|
||||
LaunchedEffect(Unit) {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
try {
|
||||
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List<Pair<ImageResource, StringResource>>) {
|
||||
@@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
) {
|
||||
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING)
|
||||
|
||||
val modalManager = if (viaSettings) ModalManager.start else ModalManager.center
|
||||
|
||||
v.features.forEach { feature ->
|
||||
if (feature.show) {
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
|
||||
when (feature) {
|
||||
is VersionFeature.FeatureDescription -> {
|
||||
if (feature.show) {
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
|
||||
}
|
||||
}
|
||||
is VersionFeature.FeatureView -> {
|
||||
feature.view(modalManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
ReadMoreButton(v.post)
|
||||
}
|
||||
|
||||
if (updatedConditions) {
|
||||
Text(
|
||||
stringResource(MR.strings.view_updated_conditions),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable {
|
||||
modalManager.showModalCloseable {
|
||||
close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!viaSettings) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(
|
||||
@@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
) {
|
||||
Text(
|
||||
generalGetString(MR.strings.ok),
|
||||
modifier = Modifier.clickable(onClick = close),
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
close()
|
||||
}),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
@@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class FeatureDescription(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val descrId: StringResource?,
|
||||
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
|
||||
val link: String? = null,
|
||||
val show: Boolean = true
|
||||
)
|
||||
private sealed class VersionFeature {
|
||||
class FeatureDescription(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val descrId: StringResource?,
|
||||
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
|
||||
val link: String? = null,
|
||||
val show: Boolean = true
|
||||
): VersionFeature()
|
||||
|
||||
class FeatureView(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val view: @Composable (modalManager: ModalManager) -> Unit
|
||||
): VersionFeature()
|
||||
}
|
||||
|
||||
private data class VersionDescription(
|
||||
val version: String,
|
||||
val features: List<FeatureDescription>,
|
||||
val features: List<VersionFeature>,
|
||||
val post: String? = null,
|
||||
)
|
||||
|
||||
@@ -186,18 +231,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v4.2",
|
||||
post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v4_2_security_assessment,
|
||||
descrId = MR.strings.v4_2_security_assessment_desc,
|
||||
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_group,
|
||||
titleId = MR.strings.v4_2_group_links,
|
||||
descrId = MR.strings.v4_2_group_links_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_check,
|
||||
titleId = MR.strings.v4_2_auto_accept_contact_requests,
|
||||
descrId = MR.strings.v4_2_auto_accept_contact_requests_desc
|
||||
@@ -208,22 +253,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v4.3",
|
||||
post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_mic,
|
||||
titleId = MR.strings.v4_3_voice_messages,
|
||||
descrId = MR.strings.v4_3_voice_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_delete_forever,
|
||||
titleId = MR.strings.v4_3_irreversible_message_deletion,
|
||||
descrId = MR.strings.v4_3_irreversible_message_deletion_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v4_3_improved_server_configuration,
|
||||
descrId = MR.strings.v4_3_improved_server_configuration_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_visibility_off,
|
||||
titleId = MR.strings.v4_3_improved_privacy_and_security,
|
||||
descrId = MR.strings.v4_3_improved_privacy_and_security_desc
|
||||
@@ -234,22 +279,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v4.4",
|
||||
post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_timer,
|
||||
titleId = MR.strings.v4_4_disappearing_messages,
|
||||
descrId = MR.strings.v4_4_disappearing_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_pending,
|
||||
titleId = MR.strings.v4_4_live_messages,
|
||||
descrId = MR.strings.v4_4_live_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v4_4_verify_connection_security,
|
||||
descrId = MR.strings.v4_4_verify_connection_security_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_4_french_interface,
|
||||
descrId = MR.strings.v4_4_french_interface_descr
|
||||
@@ -260,33 +305,33 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v4.5",
|
||||
post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_manage_accounts,
|
||||
titleId = MR.strings.v4_5_multiple_chat_profiles,
|
||||
descrId = MR.strings.v4_5_multiple_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_edit_note,
|
||||
titleId = MR.strings.v4_5_message_draft,
|
||||
descrId = MR.strings.v4_5_message_draft_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_safety_divider,
|
||||
titleId = MR.strings.v4_5_transport_isolation,
|
||||
descrId = MR.strings.v4_5_transport_isolation_descr,
|
||||
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_task,
|
||||
titleId = MR.strings.v4_5_private_filenames,
|
||||
descrId = MR.strings.v4_5_private_filenames_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_2_bar,
|
||||
titleId = MR.strings.v4_5_reduced_battery_usage,
|
||||
descrId = MR.strings.v4_5_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_5_italian_interface,
|
||||
descrId = MR.strings.v4_5_italian_interface_descr,
|
||||
@@ -297,32 +342,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v4.6",
|
||||
post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v4_6_hidden_chat_profiles,
|
||||
descrId = MR.strings.v4_6_hidden_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_flag,
|
||||
titleId = MR.strings.v4_6_group_moderation,
|
||||
descrId = MR.strings.v4_6_group_moderation_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_maps_ugc,
|
||||
titleId = MR.strings.v4_6_group_welcome_message,
|
||||
descrId = MR.strings.v4_6_group_welcome_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_call,
|
||||
titleId = MR.strings.v4_6_audio_video_calls,
|
||||
descrId = MR.strings.v4_6_audio_video_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v4_6_reduced_battery_usage,
|
||||
descrId = MR.strings.v4_6_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_6_chinese_spanish_interface,
|
||||
descrId = MR.strings.v4_6_chinese_spanish_interface_descr,
|
||||
@@ -333,17 +378,17 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.0",
|
||||
post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_upload_file,
|
||||
titleId = MR.strings.v5_0_large_files_support,
|
||||
descrId = MR.strings.v5_0_large_files_support_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_0_app_passcode,
|
||||
descrId = MR.strings.v5_0_app_passcode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_0_polish_interface,
|
||||
descrId = MR.strings.v5_0_polish_interface_descr,
|
||||
@@ -354,27 +399,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.1",
|
||||
post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_add_reaction,
|
||||
titleId = MR.strings.v5_1_message_reactions,
|
||||
descrId = MR.strings.v5_1_message_reactions_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_chat,
|
||||
titleId = MR.strings.v5_1_better_messages,
|
||||
descrId = MR.strings.v5_1_better_messages_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_light_mode,
|
||||
titleId = MR.strings.v5_1_custom_themes,
|
||||
descrId = MR.strings.v5_1_custom_themes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_1_self_destruct_passcode,
|
||||
descrId = MR.strings.v5_1_self_destruct_passcode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_1_japanese_portuguese_interface,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
@@ -385,27 +430,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.2",
|
||||
post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_check,
|
||||
titleId = MR.strings.v5_2_message_delivery_receipts,
|
||||
descrId = MR.strings.v5_2_message_delivery_receipts_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_star,
|
||||
titleId = MR.strings.v5_2_favourites_filter,
|
||||
descrId = MR.strings.v5_2_favourites_filter_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_sync_problem,
|
||||
titleId = MR.strings.v5_2_fix_encryption,
|
||||
descrId = MR.strings.v5_2_fix_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_timer,
|
||||
titleId = MR.strings.v5_2_disappear_one_message,
|
||||
descrId = MR.strings.v5_2_disappear_one_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_redeem,
|
||||
titleId = MR.strings.v5_2_more_things,
|
||||
descrId = MR.strings.v5_2_more_things_descr
|
||||
@@ -416,29 +461,29 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.3",
|
||||
post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_desktop,
|
||||
titleId = MR.strings.v5_3_new_desktop_app,
|
||||
descrId = MR.strings.v5_3_new_desktop_app_descr,
|
||||
link = "https://simplex.chat/downloads/"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_3_encrypt_local_files,
|
||||
descrId = MR.strings.v5_3_encrypt_local_files_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_search,
|
||||
titleId = MR.strings.v5_3_discover_join_groups,
|
||||
descrId = MR.strings.v5_3_discover_join_groups_descr,
|
||||
link = "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(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_theater_comedy,
|
||||
titleId = MR.strings.v5_3_simpler_incognito_mode,
|
||||
descrId = MR.strings.v5_3_simpler_incognito_mode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_3_new_interface_languages,
|
||||
descrId = MR.strings.v5_3_new_interface_languages_descr,
|
||||
@@ -449,27 +494,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.4",
|
||||
post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_desktop,
|
||||
titleId = MR.strings.v5_4_link_mobile_desktop,
|
||||
descrId = MR.strings.v5_4_link_mobile_desktop_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_group,
|
||||
titleId = MR.strings.v5_4_better_groups,
|
||||
descrId = MR.strings.v5_4_better_groups_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_theater_comedy,
|
||||
titleId = MR.strings.v5_4_incognito_groups,
|
||||
descrId = MR.strings.v5_4_incognito_groups_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_back_hand,
|
||||
titleId = MR.strings.v5_4_block_group_members,
|
||||
descrId = MR.strings.v5_4_block_group_members_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_redeem,
|
||||
titleId = MR.strings.v5_2_more_things,
|
||||
descrId = MR.strings.v5_4_more_things_descr
|
||||
@@ -480,28 +525,28 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.5",
|
||||
post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_folder_pen,
|
||||
titleId = MR.strings.v5_5_private_notes,
|
||||
descrId = MR.strings.v5_5_private_notes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_link,
|
||||
titleId = MR.strings.v5_5_simpler_connect_ui,
|
||||
descrId = MR.strings.v5_5_simpler_connect_ui_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_forum,
|
||||
titleId = MR.strings.v5_5_join_group_conversation,
|
||||
descrId = MR.strings.v5_5_join_group_conversation_descr,
|
||||
link = "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(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v5_5_message_delivery,
|
||||
descrId = MR.strings.v5_5_message_delivery_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_5_new_interface_languages,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
@@ -512,22 +557,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.6",
|
||||
post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_vpn_key_filled,
|
||||
titleId = MR.strings.v5_6_quantum_resistant_encryption,
|
||||
descrId = MR.strings.v5_6_quantum_resistant_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_ios_share,
|
||||
titleId = MR.strings.v5_6_app_data_migration,
|
||||
descrId = MR.strings.v5_6_app_data_migration_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_call,
|
||||
titleId = MR.strings.v5_6_picture_in_picture_calls,
|
||||
descrId = MR.strings.v5_6_picture_in_picture_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_back_hand,
|
||||
titleId = MR.strings.v5_6_safer_groups,
|
||||
descrId = MR.strings.v5_6_safer_groups_descr
|
||||
@@ -538,32 +583,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.7",
|
||||
post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_vpn_key_filled,
|
||||
titleId = MR.strings.v5_6_quantum_resistant_encryption,
|
||||
descrId = MR.strings.v5_7_quantum_resistant_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_forward,
|
||||
titleId = MR.strings.v5_7_forward,
|
||||
descrId = MR.strings.v5_7_forward_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_music_note,
|
||||
titleId = MR.strings.v5_7_call_sounds,
|
||||
descrId = MR.strings.v5_7_call_sounds_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_account_box,
|
||||
titleId = MR.strings.v5_7_shape_profile_images,
|
||||
descrId = MR.strings.v5_7_shape_profile_images_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v5_7_network,
|
||||
descrId = MR.strings.v5_7_network_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_7_new_interface_languages,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
@@ -574,27 +619,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v5.8",
|
||||
post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_settings_ethernet,
|
||||
titleId = MR.strings.v5_8_private_routing,
|
||||
descrId = MR.strings.v5_8_private_routing_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_palette,
|
||||
titleId = MR.strings.v5_8_chat_themes,
|
||||
descrId = MR.strings.v5_8_chat_themes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_security,
|
||||
titleId = MR.strings.v5_8_safe_files,
|
||||
descrId = MR.strings.v5_8_safe_files_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v5_8_message_delivery,
|
||||
descrId = MR.strings.v5_8_message_delivery_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_8_persian_ui,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate
|
||||
@@ -605,7 +650,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v6.0",
|
||||
post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_0_new_chat_experience,
|
||||
descrId = null,
|
||||
@@ -616,7 +661,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
MR.images.ic_match_case to MR.strings.v6_0_increase_font_size
|
||||
)
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_0_new_media_options,
|
||||
descrId = null,
|
||||
@@ -625,23 +670,23 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur,
|
||||
)
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_toast,
|
||||
titleId = MR.strings.v6_0_reachable_chat_toolbar,
|
||||
descrId = MR.strings.v6_0_reachable_chat_toolbar_descr,
|
||||
show = appPlatform.isAndroid
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_settings_ethernet,
|
||||
titleId = MR.strings.v5_8_private_routing,
|
||||
descrId = MR.strings.v6_0_private_routing_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v6_0_connection_servers_status,
|
||||
descrId = MR.strings.v6_0_connection_servers_status_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_upgrade,
|
||||
titleId = MR.strings.v6_0_upgrade_app,
|
||||
descrId = MR.strings.v6_0_upgrade_app_descr,
|
||||
@@ -653,18 +698,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v6.1",
|
||||
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v6_1_better_security,
|
||||
descrId = MR.strings.v6_1_better_security_descr,
|
||||
link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_videocam,
|
||||
titleId = MR.strings.v6_1_better_calls,
|
||||
descrId = MR.strings.v6_1_better_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_1_better_user_experience,
|
||||
descrId = null,
|
||||
@@ -678,6 +723,39 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
),
|
||||
),
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v6.2-beta.1",
|
||||
post = "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html",
|
||||
features = listOf(
|
||||
VersionFeature.FeatureView(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_2_network_decentralization,
|
||||
view = { modalManager ->
|
||||
Column {
|
||||
val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo
|
||||
Image(painterResource(src), null, modifier = Modifier.height(48.dp))
|
||||
Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp))
|
||||
Row {
|
||||
Text(
|
||||
stringResource(MR.strings.v6_2_network_decentralization_enable_flux),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable {
|
||||
modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) }
|
||||
}
|
||||
)
|
||||
Text(" ")
|
||||
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_chat,
|
||||
titleId = MR.strings.v6_2_improved_chat_navigation,
|
||||
descrId = MR.strings.v6_2_improved_chat_navigation_descr
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
@@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
@Composable
|
||||
fun PreviewWhatsNewView() {
|
||||
SimpleXTheme {
|
||||
WhatsNewView(
|
||||
val data = remember { ModalData() }
|
||||
data.WhatsNewView(
|
||||
viaSettings = true,
|
||||
close = {}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.validPort
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) {
|
||||
var presetServers by remember(rhId) { mutableStateOf(emptyList<ServerCfg>()) }
|
||||
var servers by remember { stateGetOrPut("servers") { emptyList<ServerCfg>() } }
|
||||
var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } }
|
||||
val currServers = remember(rhId) { mutableStateOf(servers) }
|
||||
val testing = rememberSaveable(rhId) { mutableStateOf(false) }
|
||||
val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } }
|
||||
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } }
|
||||
val saveDisabled = remember(servers) {
|
||||
derivedStateOf {
|
||||
servers.isEmpty() ||
|
||||
servers == currServers.value ||
|
||||
testing.value ||
|
||||
servers.none { srv ->
|
||||
val address = parseServerAddress(srv.server)
|
||||
address != null && uniqueAddress(srv, address, servers)
|
||||
} ||
|
||||
allServersDisabled.value
|
||||
}
|
||||
}
|
||||
|
||||
KeyChangeEffect(rhId) {
|
||||
servers = emptyList()
|
||||
serversAlreadyLoaded = false
|
||||
}
|
||||
|
||||
LaunchedEffect(rhId) {
|
||||
withApi {
|
||||
val res = m.controller.getUserProtoServers(rhId, serverProtocol)
|
||||
if (res != null) {
|
||||
currServers.value = res.protoServers
|
||||
presetServers = res.presetServers
|
||||
if (servers.isEmpty() && !serversAlreadyLoaded) {
|
||||
servers = currServers.value
|
||||
serversAlreadyLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val testServersJob = CancellableOnGoneJob()
|
||||
fun showServer(server: ServerCfg) {
|
||||
ModalManager.start.showModalCloseable(true) { close ->
|
||||
var old by remember { mutableStateOf(server) }
|
||||
val index = servers.indexOf(old)
|
||||
ProtocolServerView(
|
||||
m,
|
||||
old,
|
||||
serverProtocol,
|
||||
onUpdate = { updated ->
|
||||
val newServers = ArrayList(servers)
|
||||
newServers.removeAt(index)
|
||||
newServers.add(index, updated)
|
||||
old = updated
|
||||
servers = newServers
|
||||
},
|
||||
onDelete = {
|
||||
val newServers = ArrayList(servers)
|
||||
newServers.removeAt(index)
|
||||
servers = newServers
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (saveDisabled.value) close()
|
||||
else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close)
|
||||
},
|
||||
) {
|
||||
ProtocolServersLayout(
|
||||
serverProtocol,
|
||||
testing = testing.value,
|
||||
servers = servers,
|
||||
serversUnchanged = serversUnchanged.value,
|
||||
saveDisabled = saveDisabled.value,
|
||||
allServersDisabled = allServersDisabled.value,
|
||||
m.currentUser.value,
|
||||
addServer = {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.smp_servers_add),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = servers + ServerCfg.empty
|
||||
// No saving until something will be changed on the next screen to prevent blank servers on the list
|
||||
showServer(servers.last())
|
||||
}) {
|
||||
Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
ScanProtocolServer(rhId) {
|
||||
close()
|
||||
servers = servers + it
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
val hasAllPresets = hasAllPresets(presetServers, servers, m)
|
||||
if (!hasAllPresets) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset }
|
||||
}) {
|
||||
Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
testServers = {
|
||||
testServersJob.value = withLongRunningApi {
|
||||
testServers(testing, servers, m) {
|
||||
servers = it
|
||||
}
|
||||
}
|
||||
},
|
||||
resetServers = {
|
||||
servers = currServers.value
|
||||
},
|
||||
saveSMPServers = {
|
||||
saveServers(rhId, serverProtocol, currServers, servers, m)
|
||||
},
|
||||
showServer = ::showServer,
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtocolServersLayout(
|
||||
serverProtocol: ServerProtocol,
|
||||
testing: Boolean,
|
||||
servers: List<ServerCfg>,
|
||||
serversUnchanged: Boolean,
|
||||
saveDisabled: Boolean,
|
||||
allServersDisabled: Boolean,
|
||||
currentUser: User?,
|
||||
addServer: () -> Unit,
|
||||
testServers: () -> Unit,
|
||||
resetServers: () -> Unit,
|
||||
saveSMPServers: () -> Unit,
|
||||
showServer: (ServerCfg) -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.your_SMP_servers else MR.strings.your_XFTP_servers))
|
||||
|
||||
val configuredServers = servers.filter { it.preset || it.enabled }
|
||||
val otherServers = servers.filter { !(it.preset || it.enabled) }
|
||||
|
||||
if (configuredServers.isNotEmpty()) {
|
||||
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_configured else MR.strings.xftp_servers_configured).uppercase()) {
|
||||
for (srv in configuredServers) {
|
||||
SectionItemView({ showServer(srv) }, disabled = testing) {
|
||||
ProtocolServerView(serverProtocol, srv, servers, testing)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||
}
|
||||
|
||||
if (otherServers.isNotEmpty()) {
|
||||
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_other else MR.strings.xftp_servers_other).uppercase()) {
|
||||
for (srv in otherServers.filter { !(it.preset || it.enabled) }) {
|
||||
SectionItemView({ showServer(srv) }, disabled = testing) {
|
||||
ProtocolServerView(serverProtocol, srv, servers, testing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(MR.strings.smp_servers_add),
|
||||
addServer,
|
||||
disabled = testing,
|
||||
textColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
|
||||
iconColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
|
||||
}
|
||||
|
||||
SectionView {
|
||||
SectionItemView(resetServers, disabled = serversUnchanged) {
|
||||
Text(stringResource(MR.strings.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
val testServersDisabled = testing || allServersDisabled
|
||||
SectionItemView(testServers, disabled = testServersDisabled) {
|
||||
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
SectionItemView(saveSMPServers, disabled = saveDisabled) {
|
||||
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
SectionView {
|
||||
HowToButton()
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
|
||||
val address = parseServerAddress(srv.server)
|
||||
when {
|
||||
address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
|
||||
else -> ShowTestStatus(srv)
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
val text = address?.hostnames?.firstOrNull() ?: srv.server
|
||||
if (srv.enabled) {
|
||||
Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1)
|
||||
} else {
|
||||
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HowToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
stringResource(MR.strings.how_to_use_your_servers),
|
||||
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvalidServer() {
|
||||
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
|
||||
}
|
||||
|
||||
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
|
||||
address.hostnames.all { host ->
|
||||
srv.id == s.id || !srv.server.contains(host)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPresets(presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): Boolean =
|
||||
presetServers.all { hasPreset(it, servers) } ?: true
|
||||
|
||||
private fun addAllPresets(rhId: Long?, presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
|
||||
val toAdd = ArrayList<ServerCfg>()
|
||||
for (srv in presetServers) {
|
||||
if (!hasPreset(srv, servers)) {
|
||||
toAdd.add(srv)
|
||||
}
|
||||
}
|
||||
return toAdd
|
||||
}
|
||||
|
||||
private fun hasPreset(srv: ServerCfg, servers: List<ServerCfg>): Boolean =
|
||||
servers.any { it.server == srv.server }
|
||||
|
||||
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
|
||||
val resetStatus = resetTestStatus(servers)
|
||||
onUpdated(resetStatus)
|
||||
testing.value = true
|
||||
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
|
||||
testing.value = false
|
||||
if (fs.isNotEmpty()) {
|
||||
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_test_failed),
|
||||
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
|
||||
val copy = ArrayList(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
copy.removeAt(index)
|
||||
copy.add(index, server.copy(tested = null))
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, ProtocolTestFailure> {
|
||||
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
|
||||
val updatedServers = ArrayList<ServerCfg>(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
interruptIfCancelled()
|
||||
val (updatedServer, f) = testServerConnection(server, m)
|
||||
updatedServers.removeAt(index)
|
||||
updatedServers.add(index, updatedServer)
|
||||
// toList() is important. Otherwise, Compose will not redraw the screen after first update
|
||||
onUpdated(updatedServers.toList())
|
||||
if (f != null) {
|
||||
fs[serverHostname(updatedServer.server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState<List<ServerCfg>>, servers: List<ServerCfg>, m: ChatModel, afterSave: () -> Unit = {}) {
|
||||
withBGApi {
|
||||
if (m.controller.setUserProtoServers(rhId, protocol, servers)) {
|
||||
currServers.value = servers
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.smp_save_servers_question),
|
||||
confirmText = generalGetString(MR.strings.save_verb),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -25,14 +25,13 @@ import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.database.DatabaseView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrateFromDeviceView
|
||||
import chat.simplex.common.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
|
||||
@@ -102,7 +101,7 @@ fun SettingsLayout(
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
|
||||
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
|
||||
@@ -118,7 +117,7 @@ fun SettingsLayout(
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_help)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -12,12 +19,30 @@ import chat.simplex.common.views.onboarding.ReadableTextWithLink
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun UserAddressLearnMore() {
|
||||
fun UserAddressLearnMore(showCreateAddressButton: Boolean = false) {
|
||||
ColumnWithScrollBar(Modifier .padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.simplex_address), withPadding = false)
|
||||
ReadableText(MR.strings.you_can_share_your_address)
|
||||
ReadableText(MR.strings.you_wont_lose_your_contacts_if_delete_address)
|
||||
ReadableText(MR.strings.you_can_accept_or_reject_connection)
|
||||
ReadableTextWithLink(MR.strings.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address")
|
||||
|
||||
if (showCreateAddressButton) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(
|
||||
onClick = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 2, vertical = DEFAULT_PADDING),
|
||||
colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary)
|
||||
) {
|
||||
Text(stringResource(MR.strings.create_simplex_address), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ fun UserAddressView(
|
||||
chatModel: ChatModel,
|
||||
viaCreateLinkView: Boolean = false,
|
||||
shareViaProfile: Boolean = false,
|
||||
autoCreateAddress: Boolean = false,
|
||||
close: () -> Unit
|
||||
) {
|
||||
// TODO close when remote host changes
|
||||
@@ -58,6 +59,33 @@ fun UserAddressView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAddress() {
|
||||
withBGApi {
|
||||
progressIndicator = true
|
||||
val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId)
|
||||
if (connReqContact != null) {
|
||||
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
|
||||
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.share_address_with_contacts_question),
|
||||
text = generalGetString(MR.strings.add_address_to_your_profile),
|
||||
confirmText = generalGetString(MR.strings.share_verb),
|
||||
onConfirm = {
|
||||
setProfileAddress(true)
|
||||
shareViaProfile.value = true
|
||||
}
|
||||
)
|
||||
}
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(autoCreateAddress) {
|
||||
if (autoCreateAddress) {
|
||||
createAddress()
|
||||
}
|
||||
}
|
||||
val userAddress = remember { chatModel.userAddress }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -67,26 +95,7 @@ fun UserAddressView(
|
||||
userAddress = userAddress.value,
|
||||
shareViaProfile,
|
||||
onCloseHandler,
|
||||
createAddress = {
|
||||
withBGApi {
|
||||
progressIndicator = true
|
||||
val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId)
|
||||
if (connReqContact != null) {
|
||||
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
|
||||
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.share_address_with_contacts_question),
|
||||
text = generalGetString(MR.strings.add_address_to_your_profile),
|
||||
confirmText = generalGetString(MR.strings.share_verb),
|
||||
onConfirm = {
|
||||
setProfileAddress(true)
|
||||
shareViaProfile.value = true
|
||||
}
|
||||
)
|
||||
}
|
||||
progressIndicator = false
|
||||
}
|
||||
},
|
||||
createAddress = { createAddress() },
|
||||
learnMore = {
|
||||
ModalManager.start.showModal {
|
||||
UserAddressLearnMore()
|
||||
|
||||
@@ -36,7 +36,7 @@ import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>, withAuth: (block: () -> Unit) -> Unit) {
|
||||
val searchTextOrPassword = rememberSaveable { search }
|
||||
val users by remember { derivedStateOf { m.users.map { it.user } } }
|
||||
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
|
||||
@@ -48,8 +48,10 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
|
||||
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
|
||||
visibleUsersCount = visibleUsersCount(m),
|
||||
addUser = {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
withAuth {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
activateUser = { user ->
|
||||
@@ -64,68 +66,78 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
|
||||
}
|
||||
},
|
||||
removeUser = { user ->
|
||||
val text = buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.users_delete_all_chats_deleted) + "\n\n" + generalGetString(MR.strings.users_delete_profile_for) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(user.displayName)
|
||||
withAuth {
|
||||
val text = buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.users_delete_all_chats_deleted) + "\n\n" + generalGetString(MR.strings.users_delete_profile_for) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(user.displayName)
|
||||
}
|
||||
append(":")
|
||||
}
|
||||
append(":")
|
||||
}
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.users_delete_question),
|
||||
text = text,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, true, searchTextOrPassword.value.trim())
|
||||
}) {
|
||||
Text(stringResource(MR.strings.users_delete_with_connections), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false, searchTextOrPassword.value.trim())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(MR.strings.users_delete_data_only), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.users_delete_question),
|
||||
text = text,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, true, searchTextOrPassword.value.trim())
|
||||
}) {
|
||||
Text(stringResource(MR.strings.users_delete_with_connections), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false, searchTextOrPassword.value.trim())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(MR.strings.users_delete_data_only), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
unhideUser = { user ->
|
||||
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
|
||||
ModalManager.start.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) }
|
||||
close()
|
||||
withAuth {
|
||||
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
|
||||
ModalManager.start.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) }
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } }
|
||||
}
|
||||
} else {
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } }
|
||||
}
|
||||
},
|
||||
muteUser = { user ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m, onSuccess = {
|
||||
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
|
||||
}) { m.controller.apiMuteUser(user) }
|
||||
withAuth {
|
||||
withBGApi {
|
||||
setUserPrivacy(m, onSuccess = {
|
||||
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
|
||||
}) { m.controller.apiMuteUser(user) }
|
||||
}
|
||||
}
|
||||
},
|
||||
unmuteUser = { user ->
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } }
|
||||
withAuth {
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } }
|
||||
}
|
||||
},
|
||||
showHiddenProfile = { user ->
|
||||
ModalManager.start.showModalCloseable(true) { close ->
|
||||
HiddenProfileView(m, user) {
|
||||
profileHidden.value = true
|
||||
withBGApi {
|
||||
delay(10_000)
|
||||
profileHidden.value = false
|
||||
withAuth {
|
||||
ModalManager.start.showModalCloseable(true) { close ->
|
||||
HiddenProfileView(m, user) {
|
||||
profileHidden.value = true
|
||||
withBGApi {
|
||||
delay(10_000)
|
||||
profileHidden.value = false
|
||||
}
|
||||
close()
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +150,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
|
||||
@Composable
|
||||
private fun UserProfilesLayout(
|
||||
users: List<User>,
|
||||
filteredUsers: List<User>,
|
||||
filteredUsers: List<UserInfo>,
|
||||
searchTextOrPassword: MutableState<String>,
|
||||
profileHidden: MutableState<Boolean>,
|
||||
visibleUsersCount: Int,
|
||||
@@ -195,7 +207,7 @@ private fun UserProfilesLayout(
|
||||
|
||||
@Composable
|
||||
private fun UserView(
|
||||
user: User,
|
||||
userInfo: UserInfo,
|
||||
visibleUsersCount: Int,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
@@ -205,7 +217,8 @@ private fun UserView(
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
UserProfilePickerItem(user, onLongClick = { showMenu.value = true }) {
|
||||
val user = userInfo.user
|
||||
UserProfilePickerItem(user, onLongClick = { showMenu.value = true }, unreadCount = userInfo.unreadCount) {
|
||||
activateUser(user)
|
||||
}
|
||||
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
@@ -290,7 +303,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
|
||||
}
|
||||
}
|
||||
|
||||
fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
|
||||
fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<UserInfo> {
|
||||
val s = searchTextOrPassword.trim()
|
||||
val lower = s.lowercase()
|
||||
return m.users.filter { u ->
|
||||
@@ -299,7 +312,7 @@ fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
|
||||
} else {
|
||||
correctPassword(u.user, s)
|
||||
}
|
||||
}.map { it.user }
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
@@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
|
||||
import chat.simplex.res.MR
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemWithValue
|
||||
@@ -20,119 +21,260 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.getServerOperators
|
||||
import chat.simplex.common.model.ChatController.getUserServers
|
||||
import chat.simplex.common.model.ChatController.setUserServers
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingActionButton
|
||||
import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun NetworkAndServersView() {
|
||||
fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
|
||||
val currentRemoteHost by remember { chatModel.currentRemoteHost }
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList<UserOperatorServers>() } }
|
||||
val userServers = remember { stateGetOrPut("userServers") { emptyList<UserOperatorServers>() } }
|
||||
val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList<UserServersError>() } }
|
||||
|
||||
val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } }
|
||||
NetworkAndServersLayout(
|
||||
currentRemoteHost = currentRemoteHost,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
|
||||
toggleSocksProxy = { enable ->
|
||||
val def = NetCfg.defaults
|
||||
val proxyDef = NetCfg.proxyDefaults
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_enable_socks),
|
||||
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
|
||||
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == def.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == def.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_disable_socks),
|
||||
text = generalGetString(MR.strings.network_disable_socks_info),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().copy(socksProxy = null)
|
||||
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = def.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
fun onClose(close: () -> Unit): Boolean = if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) {
|
||||
chatModel.centerPanelBackgroundClickHandler = null
|
||||
close()
|
||||
false
|
||||
} else {
|
||||
showUnsavedChangesAlert(
|
||||
{
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers)
|
||||
chatModel.centerPanelBackgroundClickHandler = null
|
||||
close()
|
||||
}
|
||||
},
|
||||
{
|
||||
chatModel.centerPanelBackgroundClickHandler = null
|
||||
close()
|
||||
}
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Enables unsaved changes alert on this view and all children views.
|
||||
chatModel.centerPanelBackgroundClickHandler = {
|
||||
onClose(close = { ModalManager.start.closeModals() })
|
||||
}
|
||||
)
|
||||
}
|
||||
ModalView(close = { onClose(closeNetworkAndServers) }) {
|
||||
NetworkAndServersLayout(
|
||||
currentRemoteHost = currentRemoteHost,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
|
||||
currUserServers = currUserServers,
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
toggleSocksProxy = { enable ->
|
||||
val def = NetCfg.defaults
|
||||
val proxyDef = NetCfg.proxyDefaults
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_enable_socks),
|
||||
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
|
||||
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == def.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == def.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_disable_socks),
|
||||
text = generalGetString(MR.strings.network_disable_socks_info),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().copy(socksProxy = null)
|
||||
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = def.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun NetworkAndServersLayout(
|
||||
currentRemoteHost: RemoteHostInfo?,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
) {
|
||||
val m = chatModel
|
||||
val conditionsAction = remember { m.conditions.value.conditionsAction }
|
||||
val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
try {
|
||||
val servers = getUserServers(rh = currentRemoteHost?.remoteHostId)
|
||||
if (servers != null) {
|
||||
currUserServers.value = servers
|
||||
userServers.value = servers
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) {
|
||||
SectionItemView(
|
||||
click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } },
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
|
||||
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }}
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
|
||||
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } }
|
||||
|
||||
AppBarTitle(stringResource(MR.strings.network_and_servers))
|
||||
// TODO: Review this and socks.
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
|
||||
SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) {
|
||||
userServers.value.forEachIndexed { index, srv ->
|
||||
srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) }
|
||||
}
|
||||
}
|
||||
if (conditionsAction != null && anyOperatorEnabled.value) {
|
||||
ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId)
|
||||
}
|
||||
val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) {
|
||||
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline))
|
||||
} else null
|
||||
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
|
||||
if (footerText != null) {
|
||||
SectionTextFooter(footerText)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }})
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
|
||||
if (networkUseSocksProxy.value) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
} else {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null }
|
||||
|
||||
if (nullOperatorIndex != -1) {
|
||||
SectionItemView({
|
||||
ModalManager.start.showModal {
|
||||
YourServersView(
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
operatorIndex = nullOperatorIndex,
|
||||
rhId = currentRemoteHost?.remoteHostId
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_dns),
|
||||
stringResource(MR.strings.your_servers),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground)
|
||||
|
||||
if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
UnsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } })
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
|
||||
if (networkUseSocksProxy.value) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
} else {
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
|
||||
|
||||
SectionItemView(
|
||||
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
|
||||
disabled = saveDisabled,
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
}
|
||||
} else if (serverErrors.value.isNotEmpty()) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
|
||||
@@ -504,6 +646,165 @@ fun showWrongProxyConfigAlert() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable()
|
||||
private fun ServerOperatorRow(
|
||||
index: Int,
|
||||
operator: ServerOperator,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
rhId: Long?
|
||||
) {
|
||||
SectionItemView(
|
||||
{
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
OperatorView(
|
||||
currUserServers,
|
||||
userServers,
|
||||
serverErrors,
|
||||
index,
|
||||
rhId
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Image(
|
||||
painterResource(operator.logo),
|
||||
operator.tradeName,
|
||||
modifier = Modifier.size(24.dp),
|
||||
colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
setToSaturation(0f)
|
||||
})
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
|
||||
if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
UnsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnsavedChangesIndicator() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_edit_filled),
|
||||
stringResource(MR.strings.icon_descr_edited),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsageConditionsView(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
close: () -> Unit,
|
||||
rhId: Long?
|
||||
) {
|
||||
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, close: () -> Unit) {
|
||||
try {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
|
||||
chatModel.conditions.value = r
|
||||
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
|
||||
close()
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AcceptConditionsButton(operatorIds: List<Long>, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
labelId = MR.strings.accept_conditions,
|
||||
onboarding = null,
|
||||
enabled = operatorIds.isNotEmpty(),
|
||||
onclick = {
|
||||
scope.launch {
|
||||
acceptForOperators(rhId, operatorIds, close)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false)
|
||||
when (val conditionsAction = chatModel.conditions.value.conditionsAction) {
|
||||
is UsageConditionsAction.Review -> {
|
||||
if (conditionsAction.operators.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2)
|
||||
if (conditionsAction.deadline != null) {
|
||||
SectionTextFooter(
|
||||
text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
|
||||
is UsageConditionsAction.Accepted -> {
|
||||
if (conditionsAction.operators.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServersErrorFooter(errStr: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = stringResource(MR.strings.server_error),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier
|
||||
.size(19.sp.toDp())
|
||||
.offset(x = 2.sp.toDp())
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(
|
||||
errStr,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.smp_save_servers_question),
|
||||
confirmText = generalGetString(MR.strings.save_verb),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
fun showUpdateNetworkSettingsDialog(
|
||||
title: String,
|
||||
startsWith: String = "",
|
||||
@@ -521,6 +822,107 @@ fun showUpdateNetworkSettingsDialog(
|
||||
)
|
||||
}
|
||||
|
||||
fun updateOperatorsConditionsAcceptance(usvs: MutableState<List<UserOperatorServers>>, updatedOperators: List<ServerOperator>) {
|
||||
val modified = ArrayList(usvs.value)
|
||||
for (i in modified.indices) {
|
||||
val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue
|
||||
modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance))
|
||||
}
|
||||
usvs.value = modified
|
||||
}
|
||||
|
||||
suspend fun validateServers_(
|
||||
rhId: Long?,
|
||||
userServersToValidate: List<UserOperatorServers>,
|
||||
serverErrors: MutableState<List<UserServersError>>
|
||||
) {
|
||||
try {
|
||||
val errors = chatController.validateServers(rhId, userServersToValidate) ?: return
|
||||
serverErrors.value = errors
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
fun serversCanBeSaved(
|
||||
currUserServers: List<UserOperatorServers>,
|
||||
userServers: List<UserOperatorServers>,
|
||||
serverErrors: List<UserServersError>
|
||||
): Boolean {
|
||||
return userServers != currUserServers && serverErrors.isEmpty()
|
||||
}
|
||||
|
||||
fun globalServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalError != null) {
|
||||
return err.globalError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalSMPError != null) {
|
||||
return err.globalSMPError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun globalXFTPServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalXFTPError != null) {
|
||||
return err.globalXFTPError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun findDuplicateHosts(serverErrors: List<UserServersError>): Set<String> {
|
||||
val duplicateHostsList = serverErrors.mapNotNull { err ->
|
||||
if (err is UserServersError.DuplicateServer) {
|
||||
err.duplicateHost
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return duplicateHostsList.toSet()
|
||||
}
|
||||
|
||||
private suspend fun saveServers(
|
||||
rhId: Long?,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>
|
||||
) {
|
||||
val userServersToSave = userServers.value
|
||||
try {
|
||||
val set = setUserServers(rhId, userServersToSave)
|
||||
|
||||
if (set) {
|
||||
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
|
||||
val updatedServers = getUserServers(rhId)
|
||||
// Get updated operators to update model
|
||||
val updatedOperators = getServerOperators(rhId)
|
||||
|
||||
if (updatedOperators != null) {
|
||||
chatModel.conditions.value = updatedOperators
|
||||
}
|
||||
|
||||
if (updatedServers != null ) {
|
||||
currUserServers.value = updatedServers
|
||||
userServers.value = updatedServers
|
||||
} else {
|
||||
currUserServers.value = userServersToSave
|
||||
}
|
||||
} else {
|
||||
currUserServers.value = userServersToSave
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewNetworkAndServersLayout() {
|
||||
@@ -530,6 +932,9 @@ fun PreviewNetworkAndServersLayout() {
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
toggleSocksProxy = {},
|
||||
currUserServers = remember { mutableStateOf(emptyList()) },
|
||||
userServers = remember { mutableStateOf(emptyList()) },
|
||||
serverErrors = remember { mutableStateOf(emptyList()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun ModalData.NewServerView(
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
rhId: Long?,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val testing = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val newServer = remember { mutableStateOf(UserServer.empty) }
|
||||
|
||||
ModalView(close = {
|
||||
addServer(
|
||||
scope,
|
||||
newServer.value,
|
||||
userServers,
|
||||
serverErrors,
|
||||
rhId,
|
||||
close = close
|
||||
)
|
||||
}) {
|
||||
Box {
|
||||
NewServerLayout(
|
||||
newServer,
|
||||
testing.value,
|
||||
testServer = {
|
||||
testing.value = true
|
||||
withLongRunningApi {
|
||||
val res = testServerConnection(newServer.value, chatModel)
|
||||
if (isActive) {
|
||||
newServer.value = res.first
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewServerLayout(
|
||||
server: MutableState<UserServer>,
|
||||
testing: Boolean,
|
||||
testServer: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.smp_servers_new_server))
|
||||
CustomServer(server, testing, testServer, onDelete = null)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
fun serverProtocolAndOperator(
|
||||
server: UserServer,
|
||||
userServers: List<UserOperatorServers>
|
||||
): Pair<ServerProtocol, ServerOperator?>? {
|
||||
val serverAddress = parseServerAddress(server.server)
|
||||
return if (serverAddress != null) {
|
||||
val serverProtocol = serverAddress.serverProtocol
|
||||
val hostnames = serverAddress.hostnames
|
||||
val matchingOperator = userServers.mapNotNull { it.operator }.firstOrNull { op ->
|
||||
op.serverDomains.any { domain ->
|
||||
hostnames.any { hostname ->
|
||||
hostname.endsWith(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
Pair(serverProtocol, matchingOperator)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun addServer(
|
||||
scope: CoroutineScope,
|
||||
server: UserServer,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
rhId: Long?,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val result = serverProtocolAndOperator(server, userServers.value)
|
||||
if (result != null) {
|
||||
val (serverProtocol, matchingOperator) = result
|
||||
val operatorIndex = userServers.value.indexOfFirst { it.operator?.operatorId == matchingOperator?.operatorId }
|
||||
if (operatorIndex != -1) {
|
||||
// Create a mutable copy of the userServers list
|
||||
val updatedUserServers = userServers.value.toMutableList()
|
||||
val operatorServers = updatedUserServers[operatorIndex]
|
||||
// Create a mutable copy of the smpServers or xftpServers and add the server
|
||||
when (serverProtocol) {
|
||||
ServerProtocol.SMP -> {
|
||||
val updatedSMPServers = operatorServers.smpServers.toMutableList()
|
||||
updatedSMPServers.add(server)
|
||||
updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers)
|
||||
}
|
||||
|
||||
ServerProtocol.XFTP -> {
|
||||
val updatedXFTPServers = operatorServers.xftpServers.toMutableList()
|
||||
updatedXFTPServers.add(server)
|
||||
updatedUserServers[operatorIndex] = operatorServers.copy(xftpServers = updatedXFTPServers)
|
||||
}
|
||||
}
|
||||
|
||||
userServers.value = updatedUserServers
|
||||
close()
|
||||
matchingOperator?.let { op ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.operator_server_alert_title),
|
||||
text = String.format(generalGetString(MR.strings.server_added_to_operator__name), op.tradeName)
|
||||
)
|
||||
}
|
||||
} else { // Shouldn't happen
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_server))
|
||||
}
|
||||
} else {
|
||||
close()
|
||||
if (server.server.trim().isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
||||
text = generalGetString(MR.strings.smp_servers_check_address)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.getUsageConditions
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun ModalData.OperatorView(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
rhId: Long?
|
||||
) {
|
||||
val testing = remember { mutableStateOf(false) }
|
||||
val operator = remember { userServers.value[operatorIndex].operator_ }
|
||||
val currentUser = remember { chatModel.currentUser }.value
|
||||
|
||||
LaunchedEffect(userServers) {
|
||||
snapshotFlow { userServers.value }
|
||||
.collect { updatedServers ->
|
||||
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
ColumnWithScrollBar(Modifier.alpha(if (testing.value) 0.6f else 1f)) {
|
||||
AppBarTitle(String.format(stringResource(MR.strings.operator_servers_title), operator.tradeName))
|
||||
OperatorViewLayout(
|
||||
currUserServers,
|
||||
userServers,
|
||||
serverErrors,
|
||||
operatorIndex,
|
||||
navigateToProtocolView = { serverIndex, server, protocol ->
|
||||
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
|
||||
},
|
||||
currentUser,
|
||||
rhId,
|
||||
testing
|
||||
)
|
||||
}
|
||||
|
||||
if (testing.value) {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToProtocolView(
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
rhId: Long?,
|
||||
serverIndex: Int,
|
||||
server: UserServer,
|
||||
protocol: ServerProtocol
|
||||
) {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
ProtocolServerView(
|
||||
m = chatModel,
|
||||
server = server,
|
||||
serverProtocol = protocol,
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
onDelete = {
|
||||
if (protocol == ServerProtocol.SMP) {
|
||||
deleteSMPServer(userServers, operatorIndex, serverIndex)
|
||||
} else {
|
||||
deleteXFTPServer(userServers, operatorIndex, serverIndex)
|
||||
}
|
||||
close()
|
||||
},
|
||||
onUpdate = { updatedServer ->
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
smpServers = if (protocol == ServerProtocol.SMP) {
|
||||
this[operatorIndex].smpServers.toMutableList().apply {
|
||||
this[serverIndex] = updatedServer
|
||||
}
|
||||
} else this[operatorIndex].smpServers,
|
||||
xftpServers = if (protocol == ServerProtocol.XFTP) {
|
||||
this[operatorIndex].xftpServers.toMutableList().apply {
|
||||
this[serverIndex] = updatedServer
|
||||
}
|
||||
} else this[operatorIndex].xftpServers
|
||||
)
|
||||
}
|
||||
},
|
||||
close = close,
|
||||
rhId = rhId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OperatorViewLayout(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
|
||||
currentUser: User?,
|
||||
rhId: Long?,
|
||||
testing: MutableState<Boolean>
|
||||
) {
|
||||
val operator by remember { derivedStateOf { userServers.value[operatorIndex].operator_ } }
|
||||
val scope = rememberCoroutineScope()
|
||||
val duplicateHosts = findDuplicateHosts(serverErrors.value)
|
||||
|
||||
Column {
|
||||
SectionView(generalGetString(MR.strings.operator).uppercase()) {
|
||||
SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Box(Modifier.padding(horizontal = 2.dp)) {
|
||||
Icon(painterResource(MR.images.ic_info), null, Modifier.size(24.dp), tint = MaterialTheme.colors.primaryVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
UseOperatorToggle(
|
||||
currUserServers = currUserServers,
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
operatorIndex = operatorIndex,
|
||||
rhId = rhId
|
||||
)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
}
|
||||
} else {
|
||||
val footerText = when (val c = operator.conditionsAcceptance) {
|
||||
is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) {
|
||||
String.format(generalGetString(MR.strings.operator_conditions_accepted_on), localDate(c.acceptedAt))
|
||||
} else null
|
||||
is ConditionsAcceptance.Required -> if (operator.enabled && c.deadline != null) {
|
||||
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(c.deadline))
|
||||
} else null
|
||||
}
|
||||
if (footerText != null) {
|
||||
SectionTextFooter(footerText)
|
||||
}
|
||||
}
|
||||
|
||||
if (operator.enabled) {
|
||||
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
stringResource(MR.strings.operator_use_for_messages_receiving),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = userServers.value[operatorIndex].operator_.smpRoles.storage,
|
||||
onCheckedChange = { enabled ->
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
stringResource(MR.strings.operator_use_for_messages_private_routing),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = userServers.value[operatorIndex].operator_.smpRoles.proxy,
|
||||
onCheckedChange = { enabled ->
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
val smpErr = globalSMPServersError(serverErrors.value)
|
||||
if (smpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(smpErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preset servers can't be deleted
|
||||
if (userServers.value[operatorIndex].smpServers.any { it.preset }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
|
||||
if (!server.preset) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.SMP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val smpErr = globalSMPServersError(serverErrors.value)
|
||||
if (smpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(smpErr)
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
|
||||
if (server.deleted || server.preset) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.SMP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
stringResource(MR.strings.operator_use_for_sending),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = userServers.value[operatorIndex].operator_.xftpRoles.storage,
|
||||
onCheckedChange = { enabled ->
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
val xftpErr = globalXFTPServersError(serverErrors.value)
|
||||
if (xftpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(xftpErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preset servers can't be deleted
|
||||
if (userServers.value[operatorIndex].xftpServers.any { it.preset }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
|
||||
if (!server.preset) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.XFTP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val xftpErr = globalXFTPServersError(serverErrors.value)
|
||||
if (xftpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(xftpErr)
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
|
||||
if (server.deleted || server.preset) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.XFTP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
TestServersButton(
|
||||
testing = testing,
|
||||
smpServers = userServers.value[operatorIndex].smpServers,
|
||||
xftpServers = userServers.value[operatorIndex].xftpServers,
|
||||
) { p, l ->
|
||||
when (p) {
|
||||
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
xftpServers = l
|
||||
)
|
||||
}
|
||||
|
||||
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
smpServers = l
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperatorInfoView(serverOperator: ServerOperator) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.operator_info_title))
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
|
||||
if (serverOperator.legalName != null) {
|
||||
Text(serverOperator.legalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
serverOperator.info.description.forEach { d ->
|
||||
Text(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(generalGetString(MR.strings.operator_website).uppercase()) {
|
||||
SectionItemView {
|
||||
val website = serverOperator.info.website
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseOperatorToggle(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
rhId: Long?
|
||||
) {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
stringResource(MR.strings.operator_use_operator_toggle_description),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = userServers.value[operatorIndex].operator?.enabled ?: false,
|
||||
onCheckedChange = { enabled ->
|
||||
val operator = userServers.value[operatorIndex].operator
|
||||
if (enabled) {
|
||||
when (val conditionsAcceptance = operator?.conditionsAcceptance) {
|
||||
is ConditionsAcceptance.Accepted -> {
|
||||
changeOperatorEnabled(userServers, operatorIndex, true)
|
||||
}
|
||||
|
||||
is ConditionsAcceptance.Required -> {
|
||||
if (conditionsAcceptance.deadline == null) {
|
||||
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
SingleOperatorUsageConditionsView(
|
||||
currUserServers = currUserServers,
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
operatorIndex = operatorIndex,
|
||||
rhId = rhId,
|
||||
close = close
|
||||
)
|
||||
}
|
||||
} else {
|
||||
changeOperatorEnabled(userServers, operatorIndex, true)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
changeOperatorEnabled(userServers, operatorIndex, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleOperatorUsageConditionsView(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
rhId: Long?,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
|
||||
val operator = remember { userServers.value[operatorIndex].operator_ }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, operatorIndexToEnable: Int, close: () -> Unit) {
|
||||
try {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
|
||||
|
||||
chatModel.conditions.value = r
|
||||
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
|
||||
changeOperatorEnabled(userServers, operatorIndex, true)
|
||||
close()
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AcceptConditionsButton(close: () -> Unit) {
|
||||
// Opened operator or Other enabled operators with conditions not accepted
|
||||
val operatorIds = chatModel.conditions.value.serverOperators
|
||||
.filter { it.operatorId == operator.id || (it.enabled && !it.conditionsAcceptance.conditionsAccepted) }
|
||||
.map { it.operatorId }
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
labelId = MR.strings.accept_conditions,
|
||||
onboarding = null,
|
||||
enabled = operatorIds.isNotEmpty(),
|
||||
onclick = {
|
||||
scope.launch {
|
||||
acceptForOperators(rhId, operatorIds, operatorIndex, close)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsageConditionsDestinationView(close: () -> Unit) {
|
||||
ColumnWithScrollBar(modifier = Modifier.fillMaxSize()) {
|
||||
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false)
|
||||
Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsageConditionsNavLinkButton() {
|
||||
Text(
|
||||
stringResource(MR.strings.view_conditions),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).clickable {
|
||||
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
UsageConditionsDestinationView(close)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false)
|
||||
if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) {
|
||||
// In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
|
||||
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
} else if (operatorsWithConditionsAccepted.isNotEmpty()) {
|
||||
ReadableText(
|
||||
MR.strings.operator_conditions_accepted_for_some,
|
||||
args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }
|
||||
)
|
||||
ReadableText(
|
||||
MR.strings.operator_same_conditions_will_be_applied,
|
||||
args = operator.legalName_
|
||||
)
|
||||
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
|
||||
|
||||
UsageConditionsNavLinkButton()
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
AcceptConditionsButton(close)
|
||||
} else {
|
||||
ReadableText(
|
||||
MR.strings.operator_in_order_to_use_accept_conditions,
|
||||
args = operator.legalName_
|
||||
)
|
||||
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
|
||||
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
AcceptConditionsButton(close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConditionsTextView(
|
||||
rhId: Long?
|
||||
) {
|
||||
val conditionsData = remember { mutableStateOf<Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>?>(null) }
|
||||
val failedToLoad = remember { mutableStateOf(false) }
|
||||
val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
val conditions = getUsageConditions(rh = rhId)
|
||||
|
||||
if (conditions != null) {
|
||||
conditionsData.value = conditions
|
||||
} else {
|
||||
failedToLoad.value = true
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
failedToLoad.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
val conditions = conditionsData.value
|
||||
|
||||
if (conditions != null) {
|
||||
val (usageConditions, conditionsText, _) = conditions
|
||||
|
||||
if (conditionsText != null) {
|
||||
val scrollState = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(border = BorderStroke(1.dp, CurrentColors.value.colors.secondary.copy(alpha = 0.6f)), shape = RoundedCornerShape(12.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = conditionsText.trimIndent(),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/${usageConditions.conditionsCommit}/PRIVACY.md"
|
||||
ConditionsLinkView(conditionsLink)
|
||||
}
|
||||
} else if (failedToLoad.value) {
|
||||
ConditionsLinkView(defaultConditionsLink)
|
||||
} else {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConditionsLinkView(conditionsLink: String) {
|
||||
SectionItemView {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground)
|
||||
Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConditionsAppliedToOtherOperatorsText(userServers: List<UserOperatorServers>, operatorIndex: Int) {
|
||||
val otherOperatorsToApply = remember {
|
||||
derivedStateOf {
|
||||
chatModel.conditions.value.serverOperators.filter {
|
||||
it.enabled &&
|
||||
!it.conditionsAcceptance.conditionsAccepted &&
|
||||
it.operatorId != userServers[operatorIndex].operator_.operatorId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (otherOperatorsToApply.value.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operator_conditions_will_be_applied)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConditionsLinkButton() {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Column {
|
||||
DefaultDropdownMenu(showMenu, offset = if (oneHandUI.value) DpOffset(0.dp, -AppBarHeight * fontSizeSqrtMultiplier * 3) else DpOffset.Zero) {
|
||||
val commit = chatModel.conditions.value.currentConditions.conditionsCommit
|
||||
ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = {
|
||||
val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md"
|
||||
uriHandler.openUriCatching(mdUrl)
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = {
|
||||
val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit"
|
||||
uriHandler.openUriCatching(commitUrl)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
IconButton({ showMenu.value = true }) {
|
||||
Icon(painterResource(MR.images.ic_outbound), null, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeOperatorEnabled(userServers: MutableState<List<UserOperatorServers>>, operatorIndex: Int, enabled: Boolean) {
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(enabled = enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ProtocolServerLayout(
|
||||
testing,
|
||||
server,
|
||||
serverProtocol,
|
||||
testServer = {
|
||||
testing = true
|
||||
withLongRunningApi {
|
||||
val res = testServerConnection(server, m)
|
||||
if (isActive) {
|
||||
onUpdate(res.first)
|
||||
testing = false
|
||||
fun ProtocolServerView(
|
||||
m: ChatModel,
|
||||
server: UserServer,
|
||||
serverProtocol: ServerProtocol,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
onDelete: () -> Unit,
|
||||
onUpdate: (UserServer) -> Unit,
|
||||
close: () -> Unit,
|
||||
rhId: Long?
|
||||
) {
|
||||
val testing = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val draftServer = remember { mutableStateOf(server) }
|
||||
|
||||
ModalView(
|
||||
close = {
|
||||
scope.launch {
|
||||
val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value)
|
||||
val savedResult = serverProtocolAndOperator(server, userServers.value)
|
||||
|
||||
if (draftResult != null && savedResult != null) {
|
||||
val (serverToEditProtocol, serverToEditOperator) = draftResult
|
||||
val (svProtocol, serverOperator) = savedResult
|
||||
|
||||
if (serverToEditProtocol != svProtocol) {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_updating_server_title),
|
||||
text = generalGetString(MR.strings.error_server_protocol_changed)
|
||||
)
|
||||
} else if (serverToEditOperator != serverOperator) {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_updating_server_title),
|
||||
text = generalGetString(MR.strings.error_server_operator_changed)
|
||||
)
|
||||
} else {
|
||||
onUpdate(draftServer.value)
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
||||
text = generalGetString(MR.strings.smp_servers_check_address)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate,
|
||||
onDelete
|
||||
)
|
||||
if (testing) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
ProtocolServerLayout(
|
||||
draftServer,
|
||||
serverProtocol,
|
||||
testing.value,
|
||||
testServer = {
|
||||
testing.value = true
|
||||
withLongRunningApi {
|
||||
val res = testServerConnection(draftServer.value, m)
|
||||
if (isActive) {
|
||||
draftServer.value = res.first
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtocolServerLayout(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
server: MutableState<UserServer>,
|
||||
serverProtocol: ServerProtocol,
|
||||
testing: Boolean,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server))
|
||||
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server))
|
||||
|
||||
if (server.preset) {
|
||||
PresetServer(testing, server, testServer, onUpdate, onDelete)
|
||||
if (server.value.preset) {
|
||||
PresetServer(server, testing, testServer)
|
||||
} else {
|
||||
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete)
|
||||
CustomServer(server, testing, testServer, onDelete)
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
@@ -89,16 +128,14 @@ private fun ProtocolServerLayout(
|
||||
|
||||
@Composable
|
||||
private fun PresetServer(
|
||||
server: MutableState<UserServer>,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
testServer: () -> Unit
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
server.server,
|
||||
server.value.server,
|
||||
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
|
||||
@@ -108,23 +145,21 @@ private fun PresetServer(
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
|
||||
UseServerSection(server, true, testing, testServer)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomServer(
|
||||
fun CustomServer(
|
||||
server: MutableState<UserServer>,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
serverProtocol: ServerProtocol,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDelete: (() -> Unit)?,
|
||||
) {
|
||||
val serverAddress = remember { mutableStateOf(server.server) }
|
||||
val serverAddress = remember { mutableStateOf(server.value.server) }
|
||||
val valid = remember {
|
||||
derivedStateOf {
|
||||
with(parseServerAddress(serverAddress.value)) {
|
||||
this?.valid == true && this.serverProtocol == serverProtocol
|
||||
this?.valid == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,13 +177,14 @@ private fun CustomServer(
|
||||
snapshotFlow { serverAddress.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
testedPreviously[server.server] = server.tested
|
||||
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
|
||||
testedPreviously[server.value.server] = server.value.tested
|
||||
server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
|
||||
|
||||
UseServerSection(server, valid.value, testing, testServer, onDelete)
|
||||
|
||||
if (valid.value) {
|
||||
SectionDividerSpaced()
|
||||
@@ -160,43 +196,44 @@ private fun CustomServer(
|
||||
|
||||
@Composable
|
||||
private fun UseServerSection(
|
||||
server: MutableState<UserServer>,
|
||||
valid: Boolean,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) {
|
||||
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
ShowTestStatus(server)
|
||||
ShowTestStatus(server.value)
|
||||
}
|
||||
|
||||
val enabled = rememberUpdatedState(server.enabled)
|
||||
val enabled = rememberUpdatedState(server.value.enabled)
|
||||
PreferenceToggle(
|
||||
stringResource(MR.strings.smp_servers_use_server_for_new_conn),
|
||||
disabled = server.tested != true && !server.preset,
|
||||
disabled = testing,
|
||||
checked = enabled.value
|
||||
) {
|
||||
onUpdate(server.copy(enabled = it))
|
||||
server.value = server.value.copy(enabled = it)
|
||||
}
|
||||
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
|
||||
|
||||
if (onDelete != null) {
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
|
||||
fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) =
|
||||
when (server.tested) {
|
||||
true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen)
|
||||
false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error)
|
||||
else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent)
|
||||
}
|
||||
|
||||
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, ProtocolTestFailure?> =
|
||||
suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair<UserServer, ProtocolTestFailure?> =
|
||||
try {
|
||||
val r = m.controller.testProtoServer(server.remoteHostId, server.server)
|
||||
server.copy(tested = r == null) to r
|
||||
@@ -0,0 +1,407 @@
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ModalData.YourServersView(
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
rhId: Long?
|
||||
) {
|
||||
val testing = remember { mutableStateOf(false) }
|
||||
val currentUser = remember { chatModel.currentUser }.value
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(userServers) {
|
||||
snapshotFlow { userServers.value }
|
||||
.collect { updatedServers ->
|
||||
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.your_servers))
|
||||
YourServersViewLayout(
|
||||
scope,
|
||||
userServers,
|
||||
serverErrors,
|
||||
operatorIndex,
|
||||
navigateToProtocolView = { serverIndex, server, protocol ->
|
||||
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
|
||||
},
|
||||
currentUser,
|
||||
rhId,
|
||||
testing
|
||||
)
|
||||
}
|
||||
|
||||
if (testing.value) {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun YourServersViewLayout(
|
||||
scope: CoroutineScope,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
operatorIndex: Int,
|
||||
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
|
||||
currentUser: User?,
|
||||
rhId: Long?,
|
||||
testing: MutableState<Boolean>
|
||||
) {
|
||||
val duplicateHosts = findDuplicateHosts(serverErrors.value)
|
||||
|
||||
Column {
|
||||
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
|
||||
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
|
||||
if (server.deleted) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.SMP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val smpErr = globalSMPServersError(serverErrors.value)
|
||||
if (smpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(smpErr)
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
|
||||
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
|
||||
if (server.deleted) return@forEachIndexed
|
||||
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
|
||||
ProtocolServerViewLink(
|
||||
srv = server,
|
||||
serverProtocol = ServerProtocol.XFTP,
|
||||
duplicateHosts = duplicateHosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val xftpErr = globalXFTPServersError(serverErrors.value)
|
||||
if (xftpErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(xftpErr)
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
userServers.value[operatorIndex].smpServers.any { !it.deleted } ||
|
||||
userServers.value[operatorIndex].xftpServers.any { !it.deleted }
|
||||
) {
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
|
||||
}
|
||||
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(MR.strings.smp_servers_add),
|
||||
click = { showAddServerDialog(scope, userServers, serverErrors, rhId) },
|
||||
disabled = testing.value,
|
||||
textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
|
||||
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
|
||||
|
||||
SectionView {
|
||||
TestServersButton(
|
||||
testing = testing,
|
||||
smpServers = userServers.value[operatorIndex].smpServers,
|
||||
xftpServers = userServers.value[operatorIndex].xftpServers,
|
||||
) { p, l ->
|
||||
when (p) {
|
||||
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
xftpServers = l
|
||||
)
|
||||
}
|
||||
|
||||
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
smpServers = l
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HowToButton()
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TestServersButton(
|
||||
smpServers: List<UserServer>,
|
||||
xftpServers: List<UserServer>,
|
||||
testing: MutableState<Boolean>,
|
||||
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value }
|
||||
|
||||
SectionItemView(
|
||||
{
|
||||
scope.launch {
|
||||
testServers(testing, smpServers, xftpServers, chatModel, onUpdate)
|
||||
}
|
||||
},
|
||||
disabled = disabled.value
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddServerDialog(
|
||||
scope: CoroutineScope,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
rhId: Long?
|
||||
) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.smp_servers_add),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
NewServerView(userServers, serverErrors, rhId, close)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
ScanProtocolServer(rhId) { server ->
|
||||
addServer(
|
||||
scope,
|
||||
server,
|
||||
userServers,
|
||||
serverErrors,
|
||||
rhId,
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProtocolServerViewLink(serverProtocol: ServerProtocol, srv: UserServer, duplicateHosts: Set<String>) {
|
||||
val address = parseServerAddress(srv.server)
|
||||
when {
|
||||
address == null || !address.valid || address.serverProtocol != serverProtocol || address.hostnames.any { it in duplicateHosts } -> InvalidServer()
|
||||
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
|
||||
else -> ShowTestStatus(srv)
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
val text = address?.hostnames?.firstOrNull() ?: srv.server
|
||||
if (srv.enabled) {
|
||||
Text(text, color = MaterialTheme.colors.onBackground, maxLines = 1)
|
||||
} else {
|
||||
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HowToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
stringResource(MR.strings.how_to_use_your_servers),
|
||||
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvalidServer() {
|
||||
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
|
||||
}
|
||||
|
||||
private suspend fun testServers(
|
||||
testing: MutableState<Boolean>,
|
||||
smpServers: List<UserServer>,
|
||||
xftpServers: List<UserServer>,
|
||||
m: ChatModel,
|
||||
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
|
||||
) {
|
||||
val smpResetStatus = resetTestStatus(smpServers)
|
||||
onUpdate(ServerProtocol.SMP, smpResetStatus)
|
||||
val xftpResetStatus = resetTestStatus(xftpServers)
|
||||
onUpdate(ServerProtocol.XFTP, xftpResetStatus)
|
||||
testing.value = true
|
||||
val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) }
|
||||
val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) }
|
||||
testing.value = false
|
||||
val fs = smpFailures + xftpFailures
|
||||
if (fs.isNotEmpty()) {
|
||||
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_test_failed),
|
||||
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTestStatus(servers: List<UserServer>): List<UserServer> {
|
||||
val copy = ArrayList(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
copy.removeAt(index)
|
||||
copy.add(index, server.copy(tested = null))
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
private suspend fun runServersTest(servers: List<UserServer>, m: ChatModel, onUpdated: (List<UserServer>) -> Unit): Map<String, ProtocolTestFailure> {
|
||||
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
|
||||
val updatedServers = ArrayList<UserServer>(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
interruptIfCancelled()
|
||||
val (updatedServer, f) = testServerConnection(server, m)
|
||||
updatedServers.removeAt(index)
|
||||
updatedServers.add(index, updatedServer)
|
||||
// toList() is important. Otherwise, Compose will not redraw the screen after first update
|
||||
onUpdated(updatedServers.toList())
|
||||
if (f != null) {
|
||||
fs[serverHostname(updatedServer.server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
fun deleteXFTPServer(
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
operatorServersIndex: Int,
|
||||
serverIndex: Int
|
||||
) {
|
||||
val serverIsSaved = userServers.value[operatorServersIndex].xftpServers[serverIndex].serverId != null
|
||||
|
||||
if (serverIsSaved) {
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorServersIndex] = this[operatorServersIndex].copy(
|
||||
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
|
||||
this[serverIndex] = this[serverIndex].copy(deleted = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorServersIndex] = this[operatorServersIndex].copy(
|
||||
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
|
||||
this.removeAt(serverIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSMPServer(
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
operatorServersIndex: Int,
|
||||
serverIndex: Int
|
||||
) {
|
||||
val serverIsSaved = userServers.value[operatorServersIndex].smpServers[serverIndex].serverId != null
|
||||
|
||||
if (serverIsSaved) {
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorServersIndex] = this[operatorServersIndex].copy(
|
||||
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
|
||||
this[serverIndex] = this[serverIndex].copy(deleted = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorServersIndex] = this[operatorServersIndex].copy(
|
||||
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
|
||||
this.removeAt(serverIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,25 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import chat.simplex.common.model.UserServer
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit)
|
||||
expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit)
|
||||
|
||||
@Composable
|
||||
fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) {
|
||||
fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
|
||||
QRCodeScanner { text ->
|
||||
val res = parseServerAddress(text)
|
||||
if (res != null) {
|
||||
onNext(ServerCfg(remoteHostId = rhId, text, false, null, false))
|
||||
onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
||||
@@ -109,6 +109,16 @@
|
||||
<string name="failed_to_create_user_invalid_title">Invalid display name!</string>
|
||||
<string name="failed_to_create_user_invalid_desc">This display name is invalid. Please choose another name.</string>
|
||||
<string name="failed_to_active_user_title">Error switching profile!</string>
|
||||
<string name="failed_to_save_servers">Error saving servers</string>
|
||||
<string name="no_message_servers_configured">No message servers.</string>
|
||||
<string name="no_message_servers_configured_for_receiving">No servers to receive messages.</string>
|
||||
<string name="no_message_servers_configured_for_private_routing">No servers for private message routing.</string>
|
||||
<string name="no_media_servers_configured">No media & file servers.</string>
|
||||
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
|
||||
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
|
||||
<string name="for_chat_profile">For chat profile %s:</string>
|
||||
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
|
||||
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Connection timeout</string>
|
||||
@@ -267,7 +277,7 @@
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
|
||||
<string name="auth_stop_chat">Stop chat</string>
|
||||
<string name="auth_open_chat_console">Open chat console</string>
|
||||
<string name="auth_open_chat_profiles">Open chat profiles</string>
|
||||
<string name="auth_open_chat_profiles">Change chat profiles</string>
|
||||
<string name="auth_open_migration_to_another_device">Open migration screen</string>
|
||||
<string name="lock_not_enabled">SimpleX Lock not enabled!</string>
|
||||
<string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string>
|
||||
@@ -276,6 +286,8 @@
|
||||
<string name="message_delivery_error_title">Message delivery error</string>
|
||||
<string name="message_delivery_warning_title">Message delivery warning</string>
|
||||
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
|
||||
<string name="message_deleted_or_not_received_error_title">No message</string>
|
||||
<string name="message_deleted_or_not_received_error_desc">This message was deleted or not received yet.</string>
|
||||
|
||||
<!-- CIStatus errors -->
|
||||
<string name="ci_status_other_error">Error: %1$s</string>
|
||||
@@ -378,6 +390,7 @@
|
||||
<string name="contact_tap_to_connect">Tap to Connect</string>
|
||||
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
|
||||
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
|
||||
<string name="address_creation_instruction">Tap Create SimpleX address in the menu to create it later.</string>
|
||||
|
||||
<!-- ChatView.kt -->
|
||||
<string name="no_selected_chat">No selected chat</string>
|
||||
@@ -747,6 +760,7 @@
|
||||
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
|
||||
<string name="smp_servers_scan_qr">Scan server QR code</string>
|
||||
<string name="smp_servers_enter_manually">Enter server manually</string>
|
||||
<string name="smp_servers_new_server">New server</string>
|
||||
<string name="smp_servers_preset_server">Preset server</string>
|
||||
<string name="smp_servers_your_server">Your server</string>
|
||||
<string name="smp_servers_your_server_address">Your server address</string>
|
||||
@@ -1035,6 +1049,19 @@
|
||||
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
|
||||
<string name="use_random_passphrase">Use random passphrase</string>
|
||||
|
||||
<!-- ChooseServerOperators.kt -->
|
||||
<string name="onboarding_choose_server_operators">Choose operators</string>
|
||||
<string name="onboarding_network_operators">Network operators</string>
|
||||
<string name="onboarding_network_operators_app_will_use_different_operators">When more than one network operator is enabled, the app will use the servers of different operators for each conversation.</string>
|
||||
<string name="onboarding_network_operators_app_will_use_for_routing">For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.</string>
|
||||
<string name="onboarding_select_network_operators_to_use">Select network operators to use.</string>
|
||||
<string name="onboarding_network_operators_configure_via_settings">You can configure servers via settings.</string>
|
||||
<string name="onboarding_network_operators_conditions_will_be_accepted">Conditions will be accepted for enabled operators after 30 days.</string>
|
||||
<string name="onboarding_network_operators_conditions_you_can_configure">You can configure operators in Network & servers settings.</string>
|
||||
<string name="onboarding_network_operators_review_later">Review later</string>
|
||||
<string name="onboarding_network_operators_update">Update</string>
|
||||
<string name="onboarding_network_operators_continue">Continue</string>
|
||||
|
||||
<!-- Call -->
|
||||
<string name="incoming_video_call">Incoming video call</string>
|
||||
<string name="incoming_audio_call">Incoming audio call</string>
|
||||
@@ -1664,6 +1691,59 @@
|
||||
<string name="save_group_profile">Save group profile</string>
|
||||
<string name="error_saving_group_profile">Error saving group profile</string>
|
||||
|
||||
<!-- NetworkAndServers.kt -->
|
||||
<string name="network_preset_servers_title">Preset servers</string>
|
||||
<string name="operator_review_conditions">Review conditions</string>
|
||||
<string name="operator_conditions_accepted">Accepted conditions</string>
|
||||
<string name="operator_conditions_accepted_for_enabled_operators_on">Conditions will be automatically accepted for enabled operators on: %s.</string>
|
||||
<string name="your_servers">Your servers</string>
|
||||
<string name="operators_conditions_accepted_for"><![CDATA[Conditions are accepted for the operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operators_conditions_will_be_accepted_for"><![CDATA[Conditions will be accepted for the operator(s): <b>%s</b>.]]></string>
|
||||
|
||||
<!-- OperatorView.kt -->
|
||||
<string name="operator">Operator</string>
|
||||
<string name="operator_servers_title">%s servers</string>
|
||||
<string name="operator_info_title">Network operator</string>
|
||||
<string name="operator_website">Website</string>
|
||||
<string name="operator_conditions_accepted_on">Conditions accepted on: %s.</string>
|
||||
<string name="operator_conditions_will_be_accepted_on">Conditions will be accepted on: %s.</string>
|
||||
<string name="operator">Operator</string>
|
||||
<string name="operator_use_operator_toggle_description">Use servers</string>
|
||||
<string name="use_servers_of_operator_x">Use %s</string>
|
||||
<string name="operator_conditions_failed_to_load">Current conditions text couldn\'t be loaded, you can review conditions via this link:</string>
|
||||
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_apply_to_operators"><![CDATA[Same conditions will apply to operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_will_be_applied"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator: <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[Conditions will be accepted for operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operators_conditions_will_also_apply"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
|
||||
<string name="view_conditions">View conditions</string>
|
||||
<string name="accept_conditions">Accept conditions</string>
|
||||
<string name="operator_conditions_of_use">Conditions of use</string>
|
||||
<string name="operator_in_order_to_use_accept_conditions"><![CDATA[To use the servers of <b>%s</b>, accept conditions of use.]]></string>
|
||||
<string name="operator_use_for_messages">Use for messages</string>
|
||||
<string name="operator_use_for_messages_receiving">To receive</string>
|
||||
<string name="operator_use_for_messages_private_routing">For private routing</string>
|
||||
<string name="operator_added_message_servers">Added message servers</string>
|
||||
<string name="operator_use_for_files">Use for files</string>
|
||||
<string name="operator_use_for_sending">To send</string>
|
||||
<string name="xftp_servers_per_user">The servers for new files of your current chat profile</string>
|
||||
<string name="operator_added_xftp_servers">Added media & file servers</string>
|
||||
<string name="operator_open_conditions">Open conditions</string>
|
||||
<string name="operator_open_changes">Open changes</string>
|
||||
|
||||
<!-- ProtocolServerView.kt -->
|
||||
<string name="error_updating_server_title">Error updating server</string>
|
||||
<string name="error_server_protocol_changed">Server protocol changed.</string>
|
||||
<string name="error_server_operator_changed">Server operator changed.</string>
|
||||
|
||||
<!-- NewServerView.kt -->
|
||||
<string name="operator_server_alert_title">Operator server</string>
|
||||
<string name="server_added_to_operator__name">Server added to operator %s.</string>
|
||||
<string name="error_adding_server">Error adding server</string>
|
||||
|
||||
<!-- AdvancedNetworkSettings.kt -->
|
||||
<string name="network_option_tcp_connection">TCP connection</string>
|
||||
<string name="network_options_reset_to_defaults">Reset to defaults</string>
|
||||
@@ -2056,6 +2136,13 @@
|
||||
<string name="v6_1_message_dates_descr">Better message dates.</string>
|
||||
<string name="v6_1_forward_many_messages_descr">Forward up to 20 messages at once.</string>
|
||||
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
|
||||
<string name="v6_2_network_decentralization">Network decentralization</string>
|
||||
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
|
||||
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
|
||||
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy.</string>
|
||||
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
|
||||
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
|
||||
<string name="view_updated_conditions">View updated conditions</string>
|
||||
|
||||
<!-- CustomTimePicker -->
|
||||
<string name="custom_time_unit_seconds">seconds</string>
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m346-306 235.5-235v125.5H639V-639H415.5v57.5H540L306-347l40 41ZM480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>
|
||||
|
After Width: | Height: | Size: 724 B |
@@ -0,0 +1,158 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ChatItemsMergerTest {
|
||||
|
||||
@Test
|
||||
fun testRecalculateSplitPositions() {
|
||||
val oldItems = listOf(ChatItem.getSampleData(0), ChatItem.getSampleData(123L), ChatItem.getSampleData(124L), ChatItem.getSampleData(125L))
|
||||
|
||||
val splits1 = MutableStateFlow(listOf(123L))
|
||||
val chatState1 = ActiveChatState(splits = splits1)
|
||||
val removed1 = listOf(oldItems[1])
|
||||
val newItems1 = oldItems - removed1
|
||||
val recalc1 = recalculateChatStatePositions(chatState1)
|
||||
recalc1.removed(removed1.map { Triple(it.id, oldItems.indexOf(removed1[0]), it.isRcvNew) }, newItems1)
|
||||
assertEquals(1, splits1.value.size)
|
||||
assertEquals(124L, splits1.value.first())
|
||||
|
||||
val splits2 = MutableStateFlow(listOf(123L))
|
||||
val chatState2 = ActiveChatState(splits = splits2)
|
||||
val removed2 = listOf(oldItems[1], oldItems[2])
|
||||
val newItems2 = oldItems - removed2
|
||||
val recalc2 = recalculateChatStatePositions(chatState2)
|
||||
recalc2.removed(removed2.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed2[index]), it.isRcvNew) }, newItems2)
|
||||
assertEquals(1, splits2.value.size)
|
||||
assertEquals(125L, splits2.value.first())
|
||||
|
||||
val splits3 = MutableStateFlow(listOf(123L))
|
||||
val chatState3 = ActiveChatState(splits = splits3)
|
||||
val removed3 = listOf(oldItems[1], oldItems[2], oldItems[3])
|
||||
val newItems3 = oldItems - removed3
|
||||
val recalc3 = recalculateChatStatePositions(chatState3)
|
||||
recalc3.removed(removed3.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed3[index]), it.isRcvNew) }, newItems3)
|
||||
assertEquals(0, splits3.value.size)
|
||||
|
||||
val splits4 = MutableStateFlow(listOf(123L))
|
||||
val chatState4 = ActiveChatState(splits = splits4)
|
||||
val recalc4 = recalculateChatStatePositions(chatState4)
|
||||
recalc4.cleared()
|
||||
assertEquals(0, splits4.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testItemsMerging() {
|
||||
val items = listOf(
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(100L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.Voice, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(99L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.FullDelete, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(98L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(97L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(96L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(95L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
|
||||
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(94L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
|
||||
)
|
||||
|
||||
val unreadCount = mutableStateOf(0)
|
||||
val chatState = ActiveChatState()
|
||||
val merged1 = MergedItems.create(items, unreadCount, emptySet(), chatState)
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf(0, false,
|
||||
listOf(
|
||||
listOf(0, 100, CIMergeCategory.ChatFeature),
|
||||
listOf(1, 99, CIMergeCategory.ChatFeature)
|
||||
)
|
||||
),
|
||||
listOf(2, false,
|
||||
listOf(
|
||||
listOf(0, 98, CIMergeCategory.RcvItemDeleted),
|
||||
listOf(1, 97, CIMergeCategory.RcvItemDeleted)
|
||||
)
|
||||
),
|
||||
listOf(4, true,
|
||||
listOf(
|
||||
listOf(0, 96, null),
|
||||
)
|
||||
),
|
||||
listOf(5, true,
|
||||
listOf(
|
||||
listOf(0, 95, null),
|
||||
)
|
||||
),
|
||||
listOf(6, true,
|
||||
listOf(
|
||||
listOf(0, 94, null)
|
||||
)
|
||||
)
|
||||
).toList().toString(),
|
||||
merged1.items.map {
|
||||
listOf(
|
||||
it.startIndexInReversedItems,
|
||||
if (it is MergedItem.Grouped) it.revealed else true,
|
||||
when (it) {
|
||||
is MergedItem.Grouped -> it.items.mapIndexed { index, listItem ->
|
||||
listOf(index, listItem.item.id, listItem.item.mergeCategory)
|
||||
}
|
||||
is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory))
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
)
|
||||
|
||||
val merged2 = MergedItems.create(items, unreadCount, setOf(98L, 97L), chatState)
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf(0, false,
|
||||
listOf(
|
||||
listOf(0, 100, CIMergeCategory.ChatFeature),
|
||||
listOf(1, 99, CIMergeCategory.ChatFeature)
|
||||
)
|
||||
),
|
||||
listOf(2, true,
|
||||
listOf(
|
||||
listOf(0, 98, CIMergeCategory.RcvItemDeleted),
|
||||
)
|
||||
),
|
||||
listOf(3, true,
|
||||
listOf(
|
||||
listOf(0, 97, CIMergeCategory.RcvItemDeleted)
|
||||
)
|
||||
),
|
||||
listOf(4, true,
|
||||
listOf(
|
||||
listOf(0, 96, null),
|
||||
)
|
||||
),
|
||||
listOf(5, true,
|
||||
listOf(
|
||||
listOf(0, 95, null),
|
||||
)
|
||||
),
|
||||
listOf(6, true,
|
||||
listOf(
|
||||
listOf(0, 94, null)
|
||||
)
|
||||
)
|
||||
).toList().toString(),
|
||||
merged2.items.map {
|
||||
listOf(
|
||||
it.startIndexInReversedItems,
|
||||
if (it is MergedItem.Grouped) it.revealed else true,
|
||||
when (it) {
|
||||
is MergedItem.Grouped -> it.items.mapIndexed { index, listItem ->
|
||||
listOf(index, listItem.item.id, listItem.item.mergeCategory)
|
||||
}
|
||||
is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory))
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fun showApp() {
|
||||
} else {
|
||||
// The last possible cause that can be closed
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
}
|
||||
chatModel.activeCall.value?.let {
|
||||
withBGApi {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.simplexWindowState
|
||||
@@ -37,3 +38,6 @@ actual fun LocalWindowWidth(): Dp = with(LocalDensity.current) {
|
||||
simplexWindowState.windowState.size.width
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun LocalWindowHeight(): Dp = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
|
||||
@@ -45,7 +45,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState<
|
||||
val chat = chatModel.getChat(call.contact.id)
|
||||
if (chat != null) {
|
||||
withBGApi {
|
||||
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
|
||||
openChat(chat.remoteHostId, chat.chatInfo)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -110,7 +110,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta
|
||||
val chat = chatModel.getChat(call.contact.id)
|
||||
if (chat != null) {
|
||||
withBGApi {
|
||||
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
|
||||
openChat(chat.remoteHostId, chat.chatInfo)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||