mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6b7d8353c | |||
| 113f0cce6a | |||
| ffd0de57d5 | |||
| b24d4980b6 | |||
| 141f3c8fe5 |
@@ -10,7 +10,6 @@ on:
|
||||
- blog/**
|
||||
- docs/**
|
||||
- .github/workflows/web.yml
|
||||
- PRIVACY.md
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
|
||||
RUN cabal build exe:simplex-chat
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
|
||||
+58
-106
@@ -3,49 +3,27 @@ layout: layouts/privacy.html
|
||||
permalink: /privacy/index.html
|
||||
---
|
||||
|
||||
# SimpleX Chat Operators Privacy Policy and Conditions of Use
|
||||
# SimpleX Chat Privacy Policy and Conditions of Use
|
||||
|
||||
## Summary
|
||||
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.
|
||||
|
||||
[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.
|
||||
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.
|
||||
|
||||
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 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).
|
||||
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).
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
### General principles
|
||||
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.
|
||||
|
||||
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 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 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.
|
||||
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 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.
|
||||
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 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.
|
||||
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
|
||||
|
||||
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).
|
||||
|
||||
@@ -55,41 +33,35 @@ 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
#### Private message delivery
|
||||
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.
|
||||
|
||||
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 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).
|
||||
|
||||
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
#### 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, 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
#### Connection links privacy
|
||||
|
||||
@@ -105,8 +77,6 @@ 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.
|
||||
@@ -115,111 +85,93 @@ 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 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.
|
||||
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.
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Preset Server Operators
|
||||
### Information we may share
|
||||
|
||||
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
|
||||
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 provide general access to their servers or the data on their servers to each other.
|
||||
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 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:
|
||||
The cases when SimpleX Chat Ltd 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, operators of preset servers, or the public as required or permitted by law.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
|
||||
|
||||
### Updates
|
||||
|
||||
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
|
||||
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 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.
|
||||
Please also read our Conditions of Use of Software and Infrastructure below.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## Conditions of Use of Software and Infrastructure
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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**. 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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
Updated November 14, 2024
|
||||
Updated October 14, 2024
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
@ObservedObject var appSheetState = AppSheetState.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@@ -28,7 +27,6 @@ struct ContentView: View {
|
||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@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 showChooseLAMode = false
|
||||
@@ -252,7 +250,6 @@ struct ContentView: View {
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
|
||||
.redacted(reason: appSheetState.redactionReasons(protectScreen))
|
||||
.onAppear {
|
||||
requestNtfAuthorization()
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
|
||||
@@ -147,7 +147,6 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatDbEncrypted: Bool?
|
||||
@Published var chatDbStatus: DBMigrationResult?
|
||||
@Published var ctrlInitInProgress: Bool = false
|
||||
@Published var notificationResponse: UNNotificationResponse?
|
||||
// local authentication
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
|
||||
@@ -29,33 +29,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
private var granted = false
|
||||
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
// Handle notification when app is in background
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler handler: () -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive")
|
||||
if appStateGroupDefault.get() == .active {
|
||||
processNotificationResponse(response)
|
||||
} else {
|
||||
logger.debug("NtfManager.userNotificationCenter: remember response in model")
|
||||
ChatModel.shared.notificationResponse = response
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
|
||||
let content = response.notification.request.content
|
||||
let chatModel = ChatModel.shared
|
||||
let content = ntfResponse.notification.request.content
|
||||
let action = ntfResponse.actionIdentifier
|
||||
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||
let action = response.actionIdentifier
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||
if let userId = content.userInfo["userId"] as? Int64,
|
||||
userId != chatModel.currentUser?.userId {
|
||||
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
|
||||
@@ -77,6 +61,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
|
||||
@@ -91,6 +76,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Handle notification when the app is in foreground
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@@ -199,12 +185,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryManyEvents,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
|
||||
)
|
||||
])
|
||||
}
|
||||
@@ -230,28 +210,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest, 0))
|
||||
addNotification(createContactRequestNtf(user, contactRequest))
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(createContactConnectedNtf(user, contact, 0))
|
||||
addNotification(createContactConnectedNtf(user, contact))
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled {
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
|
||||
}
|
||||
}
|
||||
|
||||
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||
logger.debug("NtfManager.notifyCallInvitation")
|
||||
addNotification(createCallInvitationNtf(invitation, 0))
|
||||
addNotification(createCallInvitationNtf(invitation))
|
||||
}
|
||||
|
||||
func setNtfBadgeCount(_ count: Int) {
|
||||
|
||||
@@ -82,17 +82,11 @@ struct SimpleXApp: App {
|
||||
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if chatModel.chatRunning == true {
|
||||
if let ntfResponse = chatModel.notificationResponse {
|
||||
chatModel.notificationResponse = nil
|
||||
NtfManager.shared.processNotificationResponse(ntfResponse)
|
||||
}
|
||||
if appState.inactive {
|
||||
Task {
|
||||
await updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
Task {
|
||||
await updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ struct ChatItemForwardingView: View {
|
||||
ChatItemForwardingView(
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
|
||||
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||
composeState: Binding.constant(ComposeState())
|
||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||
).environmentObject(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ struct ChatView: View {
|
||||
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -116,6 +117,7 @@ struct ChatView: View {
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.environmentObject(theme)
|
||||
.privacySensitive(protectScreen)
|
||||
.confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
if let selected = selectedChatItems {
|
||||
@@ -211,126 +213,130 @@ struct ChatView: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if selectedChatItems != nil {
|
||||
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
|
||||
} else if case let .direct(contact) = cInfo {
|
||||
Button {
|
||||
Task {
|
||||
showChatInfoSheet = true
|
||||
Group {
|
||||
if selectedChatItems != nil {
|
||||
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
|
||||
} else if case let .direct(contact) = cInfo {
|
||||
Button {
|
||||
Task {
|
||||
showChatInfoSheet = true
|
||||
}
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
} label: {
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
|
||||
ChatInfoView(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
localAlias: chat.chatInfo.localAlias,
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
|
||||
GroupChatInfoView(
|
||||
chat: chat,
|
||||
groupInfo: Binding(
|
||||
get: { groupInfo },
|
||||
set: { gInfo in
|
||||
chat.chatInfo = .group(groupInfo: gInfo)
|
||||
chat.created = Date.now
|
||||
}
|
||||
),
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
|
||||
ChatInfoView(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
localAlias: chat.chatInfo.localAlias,
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
|
||||
GroupChatInfoView(
|
||||
chat: chat,
|
||||
groupInfo: Binding(
|
||||
get: { groupInfo },
|
||||
set: { gInfo in
|
||||
chat.chatInfo = .group(groupInfo: gInfo)
|
||||
chat.created = Date.now
|
||||
}
|
||||
),
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
}.privacySensitive(protectScreen)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
let isLoading = im.isLoading && im.showLoadingProgress
|
||||
if selectedChatItems != nil {
|
||||
Button {
|
||||
withAnimation {
|
||||
selectedChatItems = nil
|
||||
}
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
if callsPrefEnabled {
|
||||
if chatModel.activeCall == nil {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
endCallButton(call)
|
||||
}
|
||||
Group {
|
||||
let isLoading = im.isLoading && im.showLoadingProgress
|
||||
if selectedChatItems != nil {
|
||||
Button {
|
||||
withAnimation {
|
||||
selectedChatItems = nil
|
||||
}
|
||||
Menu {
|
||||
if !isLoading {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
if callsPrefEnabled {
|
||||
if chatModel.activeCall == nil {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
endCallButton(call)
|
||||
}
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
HStack {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
groupLinkButton()
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false
|
||||
)
|
||||
Menu {
|
||||
if !isLoading {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
} else {
|
||||
addMembersButton()
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
if !isLoading {
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
case let .group(groupInfo):
|
||||
HStack {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
groupLinkButton()
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
addMembersButton()
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
if !isLoading {
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
case .local:
|
||||
searchButton()
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
case .local:
|
||||
searchButton()
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}.privacySensitive(protectScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +287,8 @@ struct ComposeView: View {
|
||||
// this is a workaround to fire an explicit event in certain cases
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
@@ -29,28 +29,24 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField(height: _height)
|
||||
field.backgroundColor = .clear
|
||||
field.attributedText = NSAttributedString(
|
||||
string: "",
|
||||
attributes: CustomUITextField.defaultAttributes
|
||||
)
|
||||
field.text = text
|
||||
field.textAlignment = alignment(text)
|
||||
field.autocapitalizationType = .sentences
|
||||
// TODO: Reintegrate with attributed string
|
||||
// field.setOnTextChangedListener { newText, images in
|
||||
// if !disableEditing {
|
||||
// text = newText
|
||||
// field.textAlignment = alignment(text)
|
||||
// updateFont(field)
|
||||
// // Speed up the process of updating layout, reduce jumping content on screen
|
||||
// updateHeight(field)
|
||||
// self.height = field.frame.size.height
|
||||
// } else {
|
||||
// field.text = text
|
||||
// }
|
||||
// if !images.isEmpty {
|
||||
// onImagesAdded(images)
|
||||
// }
|
||||
// }
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
if !disableEditing {
|
||||
text = newText
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
// Speed up the process of updating layout, reduce jumping content on screen
|
||||
updateHeight(field)
|
||||
self.height = field.frame.size.height
|
||||
} else {
|
||||
field.text = text
|
||||
}
|
||||
if !images.isEmpty {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
@@ -61,8 +57,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
if field.markedTextRange == nil && field.text != text {
|
||||
// TODO: Reintegrate with attributed string
|
||||
// field.text = text
|
||||
field.text = text
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
@@ -96,7 +91,7 @@ private func alignment(_ text: String) -> NSTextAlignment {
|
||||
isRightToLeft(text) ? .right : .left
|
||||
}
|
||||
|
||||
class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var height: Binding<CGFloat>
|
||||
var newHeight: CGFloat = 0
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
@@ -135,7 +130,6 @@ class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
let suggestedActions = [formatMenu] + suggestedActions
|
||||
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
|
||||
return UIMenu(children: suggestedActions.map { elem in
|
||||
if let elem = elem as? UIMenu {
|
||||
@@ -178,34 +172,33 @@ class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
// TODO: Reintegrate with attributed string
|
||||
// if textView.markedTextRange == nil {
|
||||
// var images: [UploadContent] = []
|
||||
// var rangeDiff = 0
|
||||
// let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
// textView.attributedText.enumerateAttribute(
|
||||
// NSAttributedString.Key.attachment,
|
||||
// in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
// options: [],
|
||||
// using: { value, range, _ in
|
||||
// if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
// do {
|
||||
// images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
// } catch {
|
||||
// if let img = (value as? NSTextAttachment)?.image {
|
||||
// images.append(.simpleImage(image: img))
|
||||
// }
|
||||
// }
|
||||
// newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
// rangeDiff += range.length
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// if textView.attributedText != newAttributedText {
|
||||
// textView.attributedText = newAttributedText
|
||||
// }
|
||||
// onTextChanged(textView.text, images)
|
||||
// }
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
@@ -215,106 +208,6 @@ class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
onFocusChanged(false)
|
||||
}
|
||||
|
||||
// MARK: Formatting
|
||||
private var formatMenu: UIMenu {
|
||||
UIMenu(
|
||||
title: "Format",
|
||||
children: [
|
||||
UIAction(image: UIImage(systemName: "bold")) { _ in self.toggleBold() },
|
||||
UIAction(image: UIImage(systemName: "italic")) { _ in self.toggleItalic() },
|
||||
UIAction(image: UIImage(systemName: "strikethrough")) { _ in self.toggleStriketrough() },
|
||||
UIAction(title: "Mono") { _ in self.toggleMono() },
|
||||
UIMenu(
|
||||
title: "Color",
|
||||
children: [UIColor]([.red, .green, .blue, .yellow, .cyan, .magenta])
|
||||
.map { color in
|
||||
UIAction(
|
||||
image: UIImage(systemName: "circle.fill")?
|
||||
.withTintColor(color, renderingMode: .alwaysOriginal),
|
||||
handler: { _ in self.toggle(color: color) }
|
||||
)
|
||||
}
|
||||
),
|
||||
UIAction(title: "Secret") { _ in self.toggleSecret() }
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func toggleBold() {
|
||||
toggle(UIFont.boldSystemFont(ofSize: Self.bodySize), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitBold)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleItalic() {
|
||||
toggle(UIFont.italicSystemFont(ofSize: Self.bodySize), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitItalic)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleStriketrough() {
|
||||
toggle(NSUnderlineStyle.single.rawValue, for: .strikethroughStyle) {
|
||||
($0 as? Int).map { $0 == NSUnderlineStyle.single.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMono() {
|
||||
toggle(UIFont.monospacedSystemFont(ofSize: Self.bodySize, weight: .regular), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(color: UIColor) {
|
||||
toggle(color, for: .foregroundColor) {
|
||||
($0 as? UIColor).map { $0 == color }
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecret() {
|
||||
toggle(UIColor.clear, for: .foregroundColor) { value in
|
||||
(value as? UIColor).map { $0 == .clear }
|
||||
}
|
||||
toggle(UIColor.secondarySystemFill, for: .backgroundColor) { value in
|
||||
(value as? UIColor).map { $0 == .secondarySystemFill }
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles an attribute in the currently selected text range
|
||||
/// - Parameters:
|
||||
/// - attribute: Value of the attribute to be enabled or disabled
|
||||
/// - key: Key for which to apply the attribute
|
||||
/// - detect: Block which detects, if the attribute is already present within the selection
|
||||
private func toggle(
|
||||
_ attribute: Any,
|
||||
for key: NSAttributedString.Key,
|
||||
detect: (Any) -> Bool?
|
||||
) {
|
||||
var detected = false
|
||||
textStorage.enumerateAttribute(key, in: selectedRange) { value, _, stop in
|
||||
if let value, detect(value) == true {
|
||||
detected = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
if key == .backgroundColor {
|
||||
textStorage.removeAttribute(.backgroundColor, range: selectedRange)
|
||||
} else {
|
||||
textStorage.setAttributes(Self.defaultAttributes, range: selectedRange)
|
||||
}
|
||||
if !detected {
|
||||
textStorage.addAttribute(key, value: attribute, range: selectedRange)
|
||||
}
|
||||
}
|
||||
|
||||
static var bodySize: CGFloat {
|
||||
UIFont.preferredFont(forTextStyle: .body).pointSize
|
||||
}
|
||||
|
||||
static var defaultAttributes: [NSAttributedString.Key : Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.label
|
||||
]
|
||||
}
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
|
||||
@@ -40,7 +40,7 @@ struct SendMessageView: View {
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
@State private var progressByTimeout = false
|
||||
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
@@ -78,7 +78,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||
self.representer.loadPage()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
|
||||
@@ -95,6 +95,7 @@ struct ChatListView: View {
|
||||
@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_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -119,6 +120,7 @@ struct ChatListView: View {
|
||||
.modifier(
|
||||
Sheet(isPresented: $userPickerShown) {
|
||||
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
|
||||
.privacySensitive(protectScreen)
|
||||
}
|
||||
)
|
||||
.appSheet(
|
||||
@@ -142,6 +144,7 @@ struct ChatListView: View {
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode || oneHandUI)
|
||||
.privacySensitive(protectScreen)
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.onDisappear() { activeUserPickerSheet = nil }
|
||||
@@ -215,6 +218,7 @@ struct ChatListView: View {
|
||||
Spacer()
|
||||
trailingToolbarItem.padding(.bottom, padding)
|
||||
}
|
||||
.privacySensitive(protectScreen)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { scrollToSearchBar = true }
|
||||
}
|
||||
@@ -243,17 +247,20 @@ struct ChatListView: View {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
.privacySensitive(protectScreen)
|
||||
.onTapGesture {
|
||||
userPickerShown = true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var trailingToolbarItem: some View {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton()
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
Group {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton()
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
}.privacySensitive(protectScreen)
|
||||
}
|
||||
|
||||
@ViewBuilder private var chatList: some View {
|
||||
@@ -410,6 +417,7 @@ struct SubsStatusIndicator: View {
|
||||
@State private var showServersSummary = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
@@ -424,6 +432,7 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
}
|
||||
.disabled(ChatModel.shared.chatRunning != true)
|
||||
.privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
startTask()
|
||||
}
|
||||
|
||||
@@ -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(userInfo: u).offset(x: 4, y: -4)
|
||||
unreadBadge(u).offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 6)
|
||||
@@ -169,21 +169,15 @@ struct UserPicker: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnreadBadge: View {
|
||||
var userInfo: UserInfo
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
|
||||
private func unreadBadge(_ u: UserInfo) -> some View {
|
||||
let size = dynamicSize(userFont).chatInfoSize
|
||||
unreadCountText(userInfo.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
return unreadCountText(u.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: size, minHeight: size)
|
||||
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,8 @@ import SwiftUI
|
||||
class AppSheetState: ObservableObject {
|
||||
static let shared = AppSheetState()
|
||||
@Published var scenePhaseActive: Bool = false
|
||||
|
||||
func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
|
||||
!protectScreen || scenePhaseActive
|
||||
? RedactionReasons()
|
||||
: RedactionReasons.placeholder
|
||||
}
|
||||
// Scehe phase is also be inactive while faceID is requested
|
||||
@Published var biometricAuth: Bool = false
|
||||
}
|
||||
|
||||
private struct PrivacySensitive: ViewModifier {
|
||||
@@ -25,7 +21,11 @@ private struct PrivacySensitive: ViewModifier {
|
||||
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.redacted(reason: appSheetState.redactionReasons(protectScreen))
|
||||
if !protectScreen {
|
||||
content
|
||||
} else {
|
||||
content.privacySensitive(!appSheetState.scenePhaseActive && !appSheetState.biometricAuth).redacted(reason: .privacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,11 @@ func systemAuthenticate(_ reason: String, _ completed: @escaping (LAResult) -> V
|
||||
var authAvailabilityError: NSError?
|
||||
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
|
||||
logger.debug("DEBUGGING: systemAuthenticate: canEvaluatePolicy callback")
|
||||
AppSheetState.shared.biometricAuth = true
|
||||
laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
||||
logger.debug("DEBUGGING: systemAuthenticate evaluatePolicy callback")
|
||||
DispatchQueue.main.async {
|
||||
AppSheetState.shared.biometricAuth = false
|
||||
if success {
|
||||
completed(LAResult.success)
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,6 @@ struct UserWallpaperEditor: View {
|
||||
@State var themeModeOverride: ThemeModeOverride
|
||||
@State var applyToMode: DefaultThemeMode?
|
||||
@State var showMore: Bool = false
|
||||
@State var showFileImporter: Bool = false
|
||||
@Binding var globalThemeUsed: Bool
|
||||
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||
|
||||
@@ -126,27 +125,24 @@ struct UserWallpaperEditor: View {
|
||||
|
||||
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes)
|
||||
ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||
}
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||
@@ -220,7 +216,6 @@ struct ChatWallpaperEditor: View {
|
||||
@State var themeModeOverride: ThemeModeOverride
|
||||
@State var applyToMode: DefaultThemeMode?
|
||||
@State var showMore: Bool = false
|
||||
@State var showFileImporter: Bool = false
|
||||
@Binding var globalThemeUsed: Bool
|
||||
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||
|
||||
@@ -333,27 +328,24 @@ struct ChatWallpaperEditor: View {
|
||||
|
||||
CustomizeThemeColorsSection(editColor: editColor)
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes)
|
||||
ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||
}
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// UserDefault.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 14/10/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
public struct UserDefault<Value: Equatable>: DynamicProperty {
|
||||
@StateObject private var observer = UserDefaultObserver()
|
||||
let initialValue: Value
|
||||
let key: String
|
||||
let store: UserDefaults
|
||||
|
||||
public init(
|
||||
wrappedValue: Value,
|
||||
_ key: String,
|
||||
store: UserDefaults = .standard
|
||||
) {
|
||||
self.initialValue = wrappedValue
|
||||
self.key = key
|
||||
self.store = store
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
// Observer can only be accessed after the property wrapper is installed in view (runtime exception)
|
||||
observer.subscribe(to: key)
|
||||
return store.object(forKey: key) as? Value ?? initialValue
|
||||
}
|
||||
nonmutating set {
|
||||
store.set(newValue, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UserDefaultObserver: ObservableObject {
|
||||
private var subscribed = false
|
||||
|
||||
func subscribe(to key: String) {
|
||||
if !subscribed {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(userDefaultsDidChange),
|
||||
name: UserDefaults.didChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
subscribed = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func userDefaultsDidChange(_ notification: Notification) {
|
||||
Task { @MainActor in objectWillChange.send() }
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
}
|
||||
@@ -105,12 +105,7 @@ struct TerminalView: View {
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.toolbar {
|
||||
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Chat console").font(.headline)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chat console")
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
|
||||
@@ -583,14 +583,11 @@ struct CustomizeThemeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil)
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { theme in
|
||||
ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
|
||||
ThemeManager.saveAndApplyThemeOverrides(theme)
|
||||
saveThemeToDatabase(nil)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
|
||||
.interactiveDismissDisabled(true)
|
||||
}
|
||||
@@ -598,9 +595,10 @@ struct CustomizeThemeView: View {
|
||||
|
||||
struct ImportExportThemeSection: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showFileImporter: Bool
|
||||
var perChat: ThemeModeOverride?
|
||||
var perUser: ThemeModeOverrides?
|
||||
var save: (ThemeOverrides) -> Void
|
||||
@State private var showFileImporter = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
@@ -628,47 +626,39 @@ struct ImportExportThemeSection: View {
|
||||
} label: {
|
||||
Text("Import theme").foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeImporter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
var save: (ThemeOverrides) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.fileImporter(
|
||||
isPresented: $isPresented,
|
||||
allowedContentTypes: [.data/*.plainText*/],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(files) = result, let fileURL = files.first {
|
||||
do {
|
||||
var fileSize: Int? = nil
|
||||
if fileURL.startAccessingSecurityScopedResource() {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
fileSize = resourceValues.fileSize
|
||||
}
|
||||
if let fileSize = fileSize,
|
||||
// Same as Android/desktop
|
||||
fileSize <= 5_500_000 {
|
||||
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
|
||||
save(theme)
|
||||
logger.error("Saved theme from file")
|
||||
} else {
|
||||
logger.error("Error decoding theme file")
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.data/*.plainText*/],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(files) = result, let fileURL = files.first {
|
||||
do {
|
||||
var fileSize: Int? = nil
|
||||
if fileURL.startAccessingSecurityScopedResource() {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
fileSize = resourceValues.fileSize
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
)
|
||||
if let fileSize = fileSize,
|
||||
// Same as Android/desktop
|
||||
fileSize <= 5_500_000 {
|
||||
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
|
||||
save(theme)
|
||||
logger.error("Saved theme from file")
|
||||
} else {
|
||||
logger.error("Error decoding theme file")
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Appearance fileImporter error \(error.localizedDescription)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Appearance fileImporter error \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,12 +331,7 @@ struct SettingsView: View {
|
||||
chatDatabaseRow()
|
||||
NavigationLink {
|
||||
MigrateFromDevice(showProgressOnSettings: $showProgress)
|
||||
.toolbar {
|
||||
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Migrate device").font(.headline)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Migrate device")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
|
||||
@@ -21,7 +21,6 @@ struct UserProfilesView: View {
|
||||
@State private var profileHidden = false
|
||||
@State private var profileAction: UserProfileAction?
|
||||
@State private var actionPassword = ""
|
||||
@State private var navigateToProfileCreate = false
|
||||
|
||||
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
|
||||
|
||||
@@ -56,6 +55,20 @@ struct UserProfilesView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if authorized {
|
||||
userProfilesView()
|
||||
} else {
|
||||
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
|
||||
.onAppear {
|
||||
// `scenePhase` check Prevents a feedback loop
|
||||
if AppSheetState.shared.scenePhaseActive { runAuth() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) }
|
||||
|
||||
private func userProfilesView() -> some View {
|
||||
List {
|
||||
if profileHidden {
|
||||
Button {
|
||||
@@ -67,14 +80,12 @@ struct UserProfilesView: View {
|
||||
Section {
|
||||
let users = filteredUsers()
|
||||
let v = ForEach(users) { u in
|
||||
userView(u)
|
||||
userView(u.user)
|
||||
}
|
||||
if #available(iOS 16, *) {
|
||||
v.onDelete { indexSet in
|
||||
if let i = indexSet.first {
|
||||
withAuth {
|
||||
confirmDeleteUser(users[i].user)
|
||||
}
|
||||
confirmDeleteUser(users[i].user)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -82,22 +93,12 @@ struct UserProfilesView: View {
|
||||
}
|
||||
|
||||
if trimmedSearchTextOrPassword == "" {
|
||||
NavigationLink(
|
||||
destination: CreateProfile(),
|
||||
isActive: $navigateToProfileCreate
|
||||
) {
|
||||
NavigationLink {
|
||||
CreateProfile()
|
||||
} label: {
|
||||
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.")
|
||||
@@ -191,25 +192,7 @@ 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
|
||||
@@ -233,10 +216,8 @@ struct UserProfilesView: View {
|
||||
passwordField
|
||||
settingsRow("trash", color: theme.colors.secondary) {
|
||||
Button("Delete chat profile", role: .destructive) {
|
||||
withAuth {
|
||||
profileAction = nil
|
||||
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
|
||||
}
|
||||
profileAction = nil
|
||||
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
|
||||
}
|
||||
.disabled(!actionEnabled(user))
|
||||
}
|
||||
@@ -253,10 +234,8 @@ struct UserProfilesView: View {
|
||||
passwordField
|
||||
settingsRow("lock.open", color: theme.colors.secondary) {
|
||||
Button("Unhide chat profile") {
|
||||
withAuth{
|
||||
profileAction = nil
|
||||
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
|
||||
}
|
||||
profileAction = nil
|
||||
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
|
||||
}
|
||||
.disabled(!actionEnabled(user))
|
||||
}
|
||||
@@ -279,13 +258,11 @@ struct UserProfilesView: View {
|
||||
|
||||
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
|
||||
Button(title, role: .destructive) {
|
||||
withAuth {
|
||||
if let user = userToDelete {
|
||||
if passwordEntryRequired(user) {
|
||||
profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
|
||||
} else {
|
||||
alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
|
||||
}
|
||||
if let user = userToDelete {
|
||||
if passwordEntryRequired(user) {
|
||||
profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
|
||||
} else {
|
||||
alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,8 +304,7 @@ struct UserProfilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func userView(_ userInfo: UserInfo) -> some View {
|
||||
let user = userInfo.user
|
||||
@ViewBuilder private func userView(_ user: User) -> some View {
|
||||
let v = Button {
|
||||
Task {
|
||||
do {
|
||||
@@ -346,19 +322,12 @@ 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Image(systemName: "checkmark").foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,38 +335,30 @@ struct UserProfilesView: View {
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if user.hidden {
|
||||
Button("Unhide") {
|
||||
withAuth {
|
||||
if passwordEntryRequired(user) {
|
||||
profileAction = .unhideUser(user: user)
|
||||
} else {
|
||||
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
|
||||
}
|
||||
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") {
|
||||
withAuth {
|
||||
selectedUser = user
|
||||
}
|
||||
selectedUser = user
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
Group {
|
||||
if user.showNtfs {
|
||||
Button("Mute") {
|
||||
withAuth {
|
||||
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
|
||||
try await apiMuteUser(user.userId)
|
||||
}
|
||||
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
|
||||
try await apiMuteUser(user.userId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Unmute") {
|
||||
withAuth {
|
||||
setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
|
||||
}
|
||||
setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,9 +370,7 @@ struct UserProfilesView: View {
|
||||
} else {
|
||||
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button("Delete", role: .destructive) {
|
||||
withAuth {
|
||||
confirmDeleteUser(user)
|
||||
}
|
||||
confirmDeleteUser(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,52 +26,14 @@ enum NSENotification {
|
||||
case nse(UNMutableNotificationContent)
|
||||
case callkit(RcvCallInvitation)
|
||||
case empty
|
||||
}
|
||||
|
||||
public enum NSENotificationData {
|
||||
case connectionEvent(_ user: User, _ connEntity: ConnectionEntity)
|
||||
case contactConnected(_ user: any UserLike, _ contact: Contact)
|
||||
case contactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest)
|
||||
case messageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem)
|
||||
case callInvitation(_ invitation: RcvCallInvitation)
|
||||
case msgInfo(NtfMsgAckInfo)
|
||||
case noNtf
|
||||
|
||||
var callInvitation: RcvCallInvitation? {
|
||||
var isCallInvitation: Bool {
|
||||
switch self {
|
||||
case let .callInvitation(invitation): invitation
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
func notificationContent(_ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
return switch self {
|
||||
case let .connectionEvent(user, connEntity): createConnectionEventNtf(user, connEntity, badgeCount)
|
||||
case let .contactConnected(user, contact): createContactConnectedNtf(user, contact, badgeCount)
|
||||
case let .contactRequest(user, contactRequest): createContactRequestNtf(user, contactRequest, badgeCount)
|
||||
case let .messageReceived(user, cInfo, cItem): createMessageReceivedNtf(user, cInfo, cItem, badgeCount)
|
||||
case let .callInvitation(invitation): createCallInvitationNtf(invitation, badgeCount)
|
||||
case .msgInfo: UNMutableNotificationContent()
|
||||
case .noNtf: UNMutableNotificationContent()
|
||||
}
|
||||
}
|
||||
|
||||
var notificationEvent: NSENotificationData? {
|
||||
return switch self {
|
||||
case .connectionEvent: self
|
||||
case .contactConnected: self
|
||||
case .contactRequest: self
|
||||
case .messageReceived: self
|
||||
case .callInvitation: self
|
||||
case .msgInfo: nil
|
||||
case .noNtf: nil
|
||||
}
|
||||
}
|
||||
|
||||
var newMsgData: (any UserLike, ChatInfo)? {
|
||||
return switch self {
|
||||
case let .messageReceived(user, cInfo, _): (user, cInfo)
|
||||
default: nil
|
||||
case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation
|
||||
case .callkit: true
|
||||
case .empty: false
|
||||
case .msgInfo: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,10 +43,9 @@ public enum NSENotificationData {
|
||||
// or when background notification is received.
|
||||
class NSEThreads {
|
||||
static let shared = NSEThreads()
|
||||
static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
|
||||
private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
|
||||
private var allThreads: Set<UUID> = []
|
||||
var activeThreads: [(UUID, NotificationService)] = []
|
||||
var droppedNotifications: [(ChatId, NSENotificationData)] = []
|
||||
private var activeThreads: [(UUID, NotificationService)] = []
|
||||
|
||||
func newThread() -> UUID {
|
||||
NSEThreads.queue.sync {
|
||||
@@ -103,19 +64,22 @@ class NSEThreads {
|
||||
}
|
||||
}
|
||||
|
||||
func processNotification(_ id: ChatId, _ ntf: NSENotificationData) async -> Void {
|
||||
if let (_, nse) = rcvEntityThread(id),
|
||||
nse.expectedMessages[id]?.shouldProcessNtf ?? false {
|
||||
nse.processReceivedNtf(id, ntf, signalReady: true)
|
||||
func processNotification(_ id: ChatId, _ ntf: NSENotification) async -> Void {
|
||||
var waitTime: Int64 = 5_000_000000
|
||||
while waitTime > 0 {
|
||||
if let (_, nse) = rcvEntityThread(id),
|
||||
nse.shouldProcessNtf && nse.processReceivedNtf(ntf) {
|
||||
break
|
||||
} else {
|
||||
try? await Task.sleep(nanoseconds: 10_000000)
|
||||
waitTime -= 10_000000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rcvEntityThread(_ id: ChatId) -> (UUID, NotificationService)? {
|
||||
NSEThreads.queue.sync {
|
||||
// this selects the earliest thread that:
|
||||
// 1) has this connection in nse.expectedMessages
|
||||
// 2) has not completed processing messages for this connection (not ready)
|
||||
activeThreads.first(where: { (_, nse) in nse.expectedMessages[id]?.ready == false })
|
||||
activeThreads.first(where: { (_, nse) in nse.receiveEntityId == id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,38 +106,31 @@ class NSEThreads {
|
||||
}
|
||||
}
|
||||
|
||||
struct ExpectedMessage {
|
||||
var ntfConn: UserNtfConn
|
||||
var receiveConnId: String?
|
||||
var expectedMsgId: String?
|
||||
var allowedGetNextAttempts: Int
|
||||
var msgBestAttemptNtf: NSENotificationData?
|
||||
var ready: Bool
|
||||
var shouldProcessNtf: Bool
|
||||
var startedProcessingNewMsgs: Bool
|
||||
var semaphore: DispatchSemaphore
|
||||
}
|
||||
|
||||
// Notification service extension creates a new instance of the class and calls didReceive for each notification.
|
||||
// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never
|
||||
// more than one process of notification service extension exists at a time.
|
||||
// Soon after notification service delivers the last notification it is either suspended or terminated.
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
// served as notification if no message attempts (msgBestAttemptNtf) could be produced
|
||||
var serviceBestAttemptNtf: NSENotification?
|
||||
var bestAttemptNtf: NSENotification?
|
||||
var badgeCount: Int = 0
|
||||
// thread is added to allThreads here - if thread did not start chat,
|
||||
// chat does not need to be suspended but NSE state still needs to be set to "suspended".
|
||||
var threadId: UUID? = NSEThreads.shared.newThread()
|
||||
var expectedMessages: Dictionary<String, ExpectedMessage> = [:] // key is receiveEntityId
|
||||
var notificationInfo: NtfMessages?
|
||||
var receiveEntityId: String?
|
||||
var receiveConnId: String?
|
||||
var expectedMessage: String?
|
||||
var allowedGetNextAttempts: Int = 3
|
||||
// return true if the message is taken - it prevents sending it to another NotificationService instance for processing
|
||||
var shouldProcessNtf = false
|
||||
var appSubscriber: AppSubscriber?
|
||||
var returnedSuspension = false
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
logger.debug("DEBUGGING: NotificationService.didReceive")
|
||||
let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
|
||||
setServiceBestAttemptNtf(ntf)
|
||||
setBestAttemptNtf(ntf)
|
||||
self.contentHandler = contentHandler
|
||||
registerGroupDefaults()
|
||||
let appState = appStateGroupDefault.get()
|
||||
@@ -181,11 +138,13 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
switch appState {
|
||||
case .stopped:
|
||||
setBadgeCount()
|
||||
setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount))
|
||||
setBestAttemptNtf(createAppStoppedNtf())
|
||||
deliverBestAttemptNtf()
|
||||
case .suspended:
|
||||
setBadgeCount()
|
||||
receiveNtfMessages(request, contentHandler)
|
||||
case .suspending:
|
||||
setBadgeCount()
|
||||
Task {
|
||||
let state: AppState = await withCheckedContinuation { cont in
|
||||
appSubscriber = appStateSubscriber { s in
|
||||
@@ -212,9 +171,8 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
deliverBestAttemptNtf()
|
||||
}
|
||||
}
|
||||
case .active: contentHandler(UNMutableNotificationContent())
|
||||
case .activating: contentHandler(UNMutableNotificationContent())
|
||||
case .bgRefresh: contentHandler(UNMutableNotificationContent())
|
||||
default:
|
||||
deliverBestAttemptNtf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,165 +192,78 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
if let t = threadId { NSEThreads.shared.startThread(t, self) }
|
||||
let dbStatus = startChat()
|
||||
if case .ok = dbStatus,
|
||||
let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)")
|
||||
|
||||
for ntfConn in ntfConns {
|
||||
addExpectedMessage(ntfConn: ntfConn)
|
||||
}
|
||||
|
||||
let connIdsToGet = expectedMessages.compactMap { (id, _) in
|
||||
let started = NSEThreads.queue.sync {
|
||||
let canStart = checkCanStart(id)
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") }
|
||||
if canStart {
|
||||
processDroppedNotifications(id)
|
||||
expectedMessages[id]?.startedProcessingNewMsgs = true
|
||||
expectedMessages[id]?.shouldProcessNtf = true
|
||||
}
|
||||
return canStart
|
||||
let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.receivedMsg_ == nil ? 0 : 1))")
|
||||
if let connEntity = ntfInfo.connEntity_ {
|
||||
setBestAttemptNtf(
|
||||
ntfInfo.ntfsEnabled
|
||||
? .nse(createConnectionEventNtf(ntfInfo.user, connEntity))
|
||||
: .empty
|
||||
)
|
||||
if let id = connEntity.id, ntfInfo.expectedMsg_ != nil {
|
||||
notificationInfo = ntfInfo
|
||||
receiveEntityId = id
|
||||
receiveConnId = connEntity.conn.agentConnId
|
||||
let expectedMsgId = ntfInfo.expectedMsg_?.msgId
|
||||
let receivedMsgId = ntfInfo.receivedMsg_?.msgId
|
||||
logger.debug("NotificationService: receiveNtfMessages: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private), receivedMsgId = \(receivedMsgId ?? "nil", privacy: .private)")
|
||||
expectedMessage = expectedMsgId
|
||||
shouldProcessNtf = true
|
||||
return
|
||||
}
|
||||
if started {
|
||||
return expectedMessages[id]?.receiveConnId
|
||||
} else {
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") }
|
||||
expectedMessages[id]?.semaphore.wait()
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") }
|
||||
Task {
|
||||
NSEThreads.queue.sync {
|
||||
processDroppedNotifications(id)
|
||||
expectedMessages[id]?.startedProcessingNewMsgs = true
|
||||
expectedMessages[id]?.shouldProcessNtf = true
|
||||
}
|
||||
if let connId = expectedMessages[id]?.receiveConnId {
|
||||
let _ = getConnNtfMessage(connId: connId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !connIdsToGet.isEmpty {
|
||||
if let r = apiGetConnNtfMessages(connIds: connIdsToGet) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)")
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if let dbStatus = dbStatus {
|
||||
setServiceBestAttemptNtf(createErrorNtf(dbStatus, badgeCount))
|
||||
setBestAttemptNtf(createErrorNtf(dbStatus))
|
||||
}
|
||||
}
|
||||
deliverBestAttemptNtf()
|
||||
}
|
||||
|
||||
func addExpectedMessage(ntfConn: UserNtfConn) {
|
||||
if let connEntity = ntfConn.connEntity_,
|
||||
let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil {
|
||||
let expectedMsgId = ntfConn.expectedMsg_?.msgId
|
||||
logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)")
|
||||
expectedMessages[receiveEntityId] = ExpectedMessage(
|
||||
ntfConn: ntfConn,
|
||||
receiveConnId: connEntity.conn.agentConnId,
|
||||
expectedMsgId: expectedMsgId,
|
||||
allowedGetNextAttempts: 3,
|
||||
msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf,
|
||||
ready: false,
|
||||
shouldProcessNtf: false,
|
||||
startedProcessingNewMsgs: false,
|
||||
semaphore: DispatchSemaphore(value: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func checkCanStart(_ entityId: String) -> Bool {
|
||||
return !NSEThreads.shared.activeThreads.contains(where: {
|
||||
(tId, nse) in tId != threadId && nse.expectedMessages.contains(where: { $0.key == entityId })
|
||||
})
|
||||
}
|
||||
|
||||
func processDroppedNotifications(_ entityId: String) {
|
||||
if !NSEThreads.shared.droppedNotifications.isEmpty {
|
||||
let messagesToProcess = NSEThreads.shared.droppedNotifications.filter { (eId, _) in eId == entityId }
|
||||
NSEThreads.shared.droppedNotifications.removeAll(where: { (eId, _) in eId == entityId })
|
||||
for (index, (_, ntf)) in messagesToProcess.enumerated() {
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entity \(entityId, privacy: .private): processing dropped notification \(index, privacy: .private)") }
|
||||
processReceivedNtf(entityId, ntf, signalReady: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
logger.debug("DEBUGGING: NotificationService.serviceExtensionTimeWillExpire")
|
||||
deliverBestAttemptNtf(urgent: true)
|
||||
}
|
||||
|
||||
var expectingMoreMessages: Bool {
|
||||
!expectedMessages.allSatisfy { $0.value.ready }
|
||||
}
|
||||
|
||||
func processReceivedNtf(_ id: ChatId, _ ntf: NSENotificationData, signalReady: Bool) {
|
||||
guard let expectedMessage = expectedMessages[id] else {
|
||||
return
|
||||
}
|
||||
guard let expectedMsgTs = expectedMessage.ntfConn.expectedMsg_?.msgTs else {
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
return
|
||||
func processReceivedNtf(_ ntf: NSENotification) -> Bool {
|
||||
guard let ntfInfo = notificationInfo, let expectedMsgTs = ntfInfo.expectedMsg_?.msgTs else { return false }
|
||||
if !ntfInfo.user.showNotifications {
|
||||
self.setBestAttemptNtf(.empty)
|
||||
}
|
||||
if case let .msgInfo(info) = ntf {
|
||||
if info.msgId == expectedMessage.expectedMsgId {
|
||||
if info.msgId == expectedMessage {
|
||||
expectedMessage = nil
|
||||
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): expected")
|
||||
expectedMessages[id]?.expectedMsgId = nil
|
||||
if signalReady { entityReady(id) }
|
||||
self.deliverBestAttemptNtf()
|
||||
return true
|
||||
} else if let msgTs = info.msgTs_, msgTs > expectedMsgTs {
|
||||
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, let other instance to process it, stopping this one")
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
self.deliverBestAttemptNtf()
|
||||
} else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId {
|
||||
return false
|
||||
} else if allowedGetNextAttempts > 0, let receiveConnId = receiveConnId {
|
||||
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message")
|
||||
expectedMessages[id]?.allowedGetNextAttempts -= 1
|
||||
if let receivedMsg = getConnNtfMessage(connId: receiveConnId) {
|
||||
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
|
||||
allowedGetNextAttempts -= 1
|
||||
if let receivedMsg = apiGetConnNtfMessage(connId: receiveConnId) {
|
||||
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
|
||||
return true
|
||||
} else {
|
||||
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
|
||||
self.deliverBestAttemptNtf()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unknown message, let other instance to process it")
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
self.deliverBestAttemptNtf()
|
||||
return false
|
||||
}
|
||||
} else if ntfInfo.user.showNotifications {
|
||||
logger.debug("NotificationService processNtf: setting best attempt")
|
||||
self.setBestAttemptNtf(ntf)
|
||||
if ntf.isCallInvitation {
|
||||
self.deliverBestAttemptNtf()
|
||||
}
|
||||
} else if expectedMessage.ntfConn.user.showNotifications {
|
||||
logger.debug("NotificationService processNtf: setting best attempt")
|
||||
if ntf.notificationEvent != nil {
|
||||
setBadgeCount()
|
||||
}
|
||||
let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf
|
||||
if prevBestAttempt?.callInvitation != nil {
|
||||
if ntf.callInvitation != nil { // replace with newer call
|
||||
expectedMessages[id]?.msgBestAttemptNtf = ntf
|
||||
} // otherwise keep call as best attempt
|
||||
} else {
|
||||
expectedMessages[id]?.msgBestAttemptNtf = ntf
|
||||
}
|
||||
} else {
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
}
|
||||
}
|
||||
|
||||
func entityReady(_ entityId: ChatId) {
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: entity \(entityId, privacy: .private)") }
|
||||
expectedMessages[entityId]?.ready = true
|
||||
if let (tNext, nse) = NSEThreads.shared.activeThreads.first(where: { (_, nse) in nse.expectedMessages[entityId]?.startedProcessingNewMsgs == false }) {
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: signal next thread \(tNext, privacy: .private) for entity \(entityId, privacy: .private)") }
|
||||
nse.expectedMessages[entityId]?.semaphore.signal()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setBadgeCount() {
|
||||
@@ -400,32 +271,37 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
ntfBadgeCountGroupDefault.set(badgeCount)
|
||||
}
|
||||
|
||||
func setServiceBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
|
||||
logger.debug("NotificationService.setServiceBestAttemptNtf")
|
||||
serviceBestAttemptNtf = .nse(ntf)
|
||||
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
|
||||
setBestAttemptNtf(.nse(ntf))
|
||||
}
|
||||
|
||||
private func deliverBestAttemptNtf(urgent: Bool = false) {
|
||||
if (urgent || !expectingMoreMessages) {
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf")
|
||||
// stop processing other messages
|
||||
for (key, _) in expectedMessages {
|
||||
expectedMessages[key]?.shouldProcessNtf = false
|
||||
}
|
||||
|
||||
let suspend: Bool
|
||||
if let t = threadId {
|
||||
threadId = nil
|
||||
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
|
||||
} else {
|
||||
suspend = false
|
||||
}
|
||||
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
|
||||
func setBestAttemptNtf(_ ntf: NSENotification) {
|
||||
logger.debug("NotificationService.setBestAttemptNtf")
|
||||
if case let .nse(notification) = ntf {
|
||||
notification.badge = badgeCount as NSNumber
|
||||
bestAttemptNtf = .nse(notification)
|
||||
} else {
|
||||
bestAttemptNtf = ntf
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverBestAttemptNtf(urgent: Bool = false) {
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf")
|
||||
// stop processing other messages
|
||||
shouldProcessNtf = false
|
||||
|
||||
let suspend: Bool
|
||||
if let t = threadId {
|
||||
threadId = nil
|
||||
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
|
||||
} else {
|
||||
suspend = false
|
||||
}
|
||||
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
|
||||
}
|
||||
|
||||
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) {
|
||||
if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) {
|
||||
if case .callkit = bestAttemptNtf {
|
||||
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit")
|
||||
if urgent {
|
||||
// suspending NSE even though there may be other notifications
|
||||
@@ -463,13 +339,19 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
private func deliverNotification() {
|
||||
if let handler = contentHandler, let ntf = prepareNotification() {
|
||||
if let handler = contentHandler, let ntf = bestAttemptNtf {
|
||||
contentHandler = nil
|
||||
serviceBestAttemptNtf = nil
|
||||
bestAttemptNtf = nil
|
||||
let deliver: (UNMutableNotificationContent?) -> Void = { ntf in
|
||||
let useNtf = if let ntf = ntf {
|
||||
appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf
|
||||
} else {
|
||||
UNMutableNotificationContent()
|
||||
}
|
||||
handler(useNtf)
|
||||
}
|
||||
switch ntf {
|
||||
case let .nse(content):
|
||||
content.badge = badgeCount as NSNumber
|
||||
handler(content)
|
||||
case let .nse(content): deliver(content)
|
||||
case let .callkit(invitation):
|
||||
logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)")
|
||||
CXProvider.reportNewIncomingVoIPPushPayload([
|
||||
@@ -480,85 +362,13 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
"callTs": invitation.callTs.timeIntervalSince1970
|
||||
]) { error in
|
||||
logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
|
||||
handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(invitation, self.badgeCount))
|
||||
deliver(error == nil ? nil : createCallInvitationNtf(invitation))
|
||||
}
|
||||
case .empty:
|
||||
handler(UNMutableNotificationContent()) // used to mute notifications that did not unsubscribe yet
|
||||
case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet
|
||||
case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareNotification() -> NSENotification? {
|
||||
if expectedMessages.isEmpty {
|
||||
return serviceBestAttemptNtf
|
||||
} else if let callNtfKV = expectedMessages.first(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }),
|
||||
let callInv = callNtfKV.value.msgBestAttemptNtf?.callInvitation,
|
||||
let callNtf = callNtfKV.value.msgBestAttemptNtf {
|
||||
return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount))
|
||||
} else {
|
||||
let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent }
|
||||
if ntfEvents.isEmpty {
|
||||
return .empty
|
||||
} else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil {
|
||||
return .nse(ntfEvent.notificationContent(badgeCount))
|
||||
} else {
|
||||
return .nse(createJointNtf(ntfEvents))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createJointNtf(_ ntfEvents: [NSENotificationData]) -> UNMutableNotificationContent {
|
||||
let previewMode = ntfPreviewModeGroupDefault.get()
|
||||
let newMsgsData: [(any UserLike, ChatInfo)] = ntfEvents.compactMap { $0.newMsgData }
|
||||
if !newMsgsData.isEmpty, let userId = newMsgsData.first?.0.userId {
|
||||
let newMsgsChats: [ChatInfo] = newMsgsData.map { $0.1 }
|
||||
let uniqueChatsNames = uniqueNewMsgsChatsNames(newMsgsChats)
|
||||
var body: String
|
||||
if previewMode == .hidden {
|
||||
body = String.localizedStringWithFormat(NSLocalizedString("New messages in %d chats", comment: "notification body"), uniqueChatsNames.count)
|
||||
} else {
|
||||
body = String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(uniqueChatsNames))
|
||||
}
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryManyEvents,
|
||||
title: NSLocalizedString("New messages", comment: "notification"),
|
||||
body: body,
|
||||
userInfo: ["userId": userId],
|
||||
badgeCount: badgeCount
|
||||
)
|
||||
} else {
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryManyEvents,
|
||||
title: NSLocalizedString("New events", comment: "notification"),
|
||||
body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfEvents.count),
|
||||
badgeCount: badgeCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueNewMsgsChatsNames(_ newMsgsChats: [ChatInfo]) -> [String] {
|
||||
var seenChatIds = Set<ChatId>()
|
||||
var uniqueChatsNames: [String] = []
|
||||
for chat in newMsgsChats {
|
||||
if !seenChatIds.contains(chat.id) {
|
||||
seenChatIds.insert(chat.id)
|
||||
uniqueChatsNames.append(chat.chatViewName)
|
||||
}
|
||||
}
|
||||
return uniqueChatsNames
|
||||
}
|
||||
|
||||
private func newMsgsChatsNamesStr(_ names: [String]) -> String {
|
||||
return switch names.count {
|
||||
case 1: names[0]
|
||||
case 2: "\(names[0]) and \(names[1])"
|
||||
case 3: "\(names[0] + ", " + names[1]) and \(names[2])"
|
||||
default:
|
||||
names.count > 3
|
||||
? "\(names[0]), \(names[1]) and \(names.count - 2) other chats"
|
||||
: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nseStateGroupDefault must not be used in NSE directly, only via this singleton
|
||||
@@ -772,25 +582,28 @@ func chatRecvMsg() async -> ChatResponse? {
|
||||
private let isInChina = SKStorefront().countryCode == "CHN"
|
||||
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
|
||||
|
||||
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? {
|
||||
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
|
||||
logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactConnected(user, contact, _):
|
||||
return (contact.id, .contactConnected(user, contact))
|
||||
return (contact.id, .nse(createContactConnectedNtf(user, contact)))
|
||||
// case let .contactConnecting(contact):
|
||||
// TODO profile update
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
return (UserContact(contactRequest: contactRequest).id, .contactRequest(user, contactRequest))
|
||||
return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest)))
|
||||
case let .newChatItems(user, chatItems):
|
||||
// Received items are created one at a time
|
||||
if let chatItem = chatItems.first {
|
||||
let cInfo = chatItem.chatInfo
|
||||
var cItem = chatItem.chatItem
|
||||
if !cInfo.ntfsEnabled {
|
||||
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
cItem = autoReceiveFile(file) ?? cItem
|
||||
}
|
||||
let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
|
||||
return (chatItem.chatId, ntf)
|
||||
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
|
||||
return cItem.showNotification ? (chatItem.chatId, ntf) : nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -807,7 +620,10 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)?
|
||||
return nil
|
||||
case let .callInvitation(invitation):
|
||||
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
|
||||
return (invitation.contact.id, .callInvitation(invitation))
|
||||
return (
|
||||
invitation.contact.id,
|
||||
useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation))
|
||||
)
|
||||
case let .ntfMessage(_, connEntity, ntfMessage):
|
||||
return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil }
|
||||
case .chatSuspended:
|
||||
@@ -888,15 +704,15 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
|
||||
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
|
||||
guard apiGetActiveUser() != nil else {
|
||||
logger.debug("no active user")
|
||||
return nil
|
||||
}
|
||||
let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo))
|
||||
if case let .ntfConns(ntfConns) = r {
|
||||
logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)")
|
||||
return ntfConns.compactMap { toUserNtfConn($0) }
|
||||
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
|
||||
if case let .ntfMessages(user, connEntity_, expectedMsg_, receivedMsg_) = r, let user = user {
|
||||
logger.debug("apiGetNtfMessage response ntfMessages: \(receivedMsg_ == nil ? 0 : 1)")
|
||||
return NtfMessages(user: user, connEntity_: connEntity_, expectedMsg_: expectedMsg_, receivedMsg_: receivedMsg_)
|
||||
} else if case let .chatCmdError(_, error) = r {
|
||||
logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))")
|
||||
} else {
|
||||
@@ -905,33 +721,17 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? {
|
||||
if let user = ntfConn.user_ {
|
||||
return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? {
|
||||
func apiGetConnNtfMessage(connId: String) -> NtfMsgInfo? {
|
||||
guard apiGetActiveUser() != nil else {
|
||||
logger.debug("no active user")
|
||||
return nil
|
||||
}
|
||||
let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds))
|
||||
if case let .connNtfMessages(receivedMsgs) = r {
|
||||
logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)")
|
||||
return receivedMsgs
|
||||
}
|
||||
logger.debug("apiGetConnNtfMessages error: \(responseError(r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func getConnNtfMessage(connId: String) -> NtfMsgInfo? {
|
||||
let r_ = apiGetConnNtfMessages(connIds: [connId])
|
||||
if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil {
|
||||
return receivedMsg
|
||||
let r = sendSimpleXCmd(.apiGetConnNtfMessage(connId: connId))
|
||||
if case let .connNtfMessage(receivedMsg_) = r {
|
||||
logger.debug("apiGetConnNtfMessage response receivedMsg_: \(receivedMsg_ == nil ? 0 : 1)")
|
||||
return receivedMsg_
|
||||
}
|
||||
logger.debug("apiGetConnNtfMessage error: \(responseError(r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -969,33 +769,13 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
struct UserNtfConn {
|
||||
struct NtfMessages {
|
||||
var user: User
|
||||
var connEntity_: ConnectionEntity?
|
||||
var expectedMsg_: NtfMsgInfo?
|
||||
var receivedMsg_: NtfMsgInfo?
|
||||
|
||||
var defaultBestAttemptNtf: NSENotificationData {
|
||||
return if !user.showNotifications {
|
||||
.noNtf
|
||||
} else if let connEntity = connEntity_ {
|
||||
switch connEntity {
|
||||
case let .rcvDirectMsgConnection(_, contact):
|
||||
contact?.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case let .rcvGroupMsgConnection(_, groupInfo, _):
|
||||
groupInfo.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case .sndFileConnection: .noNtf
|
||||
case .rcvFileConnection: .noNtf
|
||||
case let .userContactConnection(_, userContact):
|
||||
userContact.groupId == nil
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
}
|
||||
} else {
|
||||
.noNtf
|
||||
}
|
||||
var ntfsEnabled: Bool {
|
||||
user.showNotifications && (connEntity_?.ntfsEnabled ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,11 +148,6 @@
|
||||
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
|
||||
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -209,7 +204,6 @@
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = CE38A29B2C3FCD72005ED185 /* SwiftyGif */; };
|
||||
CE75480A2C622630009579B7 /* SwipeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7548092C622630009579B7 /* SwipeLabel.swift */; };
|
||||
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; };
|
||||
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */; };
|
||||
CEDB245B2C9CD71800FBC5F6 /* StickyScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */; };
|
||||
CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE70212C48FD9500233B1F /* SEChatState.swift */; };
|
||||
CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */; };
|
||||
@@ -229,6 +223,11 @@
|
||||
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
|
||||
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
|
||||
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; };
|
||||
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C42CBA891A00D7A2FA /* libgmpxx.a */; };
|
||||
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C52CBA891A00D7A2FA /* libgmp.a */; };
|
||||
E5E997CB2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */; };
|
||||
E5E997CC2CBA891A00D7A2FA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C72CBA891A00D7A2FA /* libffi.a */; };
|
||||
E5E997CD2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -491,11 +490,6 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -551,7 +545,6 @@
|
||||
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
|
||||
CE7548092C622630009579B7 /* SwipeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeLabel.swift; sourceTree = "<group>"; };
|
||||
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = "<group>"; };
|
||||
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
|
||||
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyScrollView.swift; sourceTree = "<group>"; };
|
||||
CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = "<group>"; };
|
||||
CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX SE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -619,6 +612,11 @@
|
||||
E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E5E997C52CBA891A00D7A2FA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E5E997C72CBA891A00D7A2FA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -657,14 +655,14 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
|
||||
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */,
|
||||
E5E997CB2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a in Frameworks */,
|
||||
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
E5E997CC2CBA891A00D7A2FA /* libffi.a 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 */,
|
||||
E5E997CD2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -741,6 +739,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E5E997C72CBA891A00D7A2FA /* libffi.a */,
|
||||
E5E997C52CBA891A00D7A2FA /* libgmp.a */,
|
||||
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */,
|
||||
E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */,
|
||||
E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -799,7 +802,6 @@
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
|
||||
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
|
||||
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
|
||||
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -812,11 +814,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 */,
|
||||
@@ -1485,7 +1482,6 @@
|
||||
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
|
||||
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
|
||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
|
||||
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
|
||||
@@ -1903,7 +1899,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1928,7 +1924,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES_THIN;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1952,7 +1948,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1977,7 +1973,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1993,11 +1989,11 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2013,11 +2009,11 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2038,7 +2034,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 = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
@@ -2053,7 +2049,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -2075,7 +2071,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 = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
@@ -2090,7 +2086,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -2112,7 +2108,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 = 244;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2138,7 +2134,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2163,7 +2159,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 = 244;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2189,7 +2185,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2214,7 +2210,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 = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -2229,7 +2225,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2248,7 +2244,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 = 244;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -2263,7 +2259,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.1.1;
|
||||
MARKETING_VERSION = 6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -55,8 +55,8 @@ public enum ChatCommand {
|
||||
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
|
||||
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
|
||||
case apiDeleteToken(token: DeviceToken)
|
||||
case apiGetNtfConns(nonce: String, encNtfInfo: String)
|
||||
case apiGetConnNtfMessages(connIds: [String])
|
||||
case apiGetNtfMessage(nonce: String, encNtfInfo: String)
|
||||
case apiGetConnNtfMessage(connId: String)
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
@@ -214,8 +214,8 @@ public enum ChatCommand {
|
||||
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
|
||||
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
|
||||
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
|
||||
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
|
||||
case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))"
|
||||
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
|
||||
case let .apiGetConnNtfMessage(connId): return "/_ntf conn message \(connId)"
|
||||
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
|
||||
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
|
||||
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
|
||||
@@ -369,8 +369,8 @@ public enum ChatCommand {
|
||||
case .apiRegisterToken: return "apiRegisterToken"
|
||||
case .apiVerifyToken: return "apiVerifyToken"
|
||||
case .apiDeleteToken: return "apiDeleteToken"
|
||||
case .apiGetNtfConns: return "apiGetNtfConns"
|
||||
case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
|
||||
case .apiGetNtfMessage: return "apiGetNtfMessage"
|
||||
case .apiGetConnNtfMessage: return "apiGetConnNtfMessage"
|
||||
case .apiNewGroup: return "apiNewGroup"
|
||||
case .apiAddMember: return "apiAddMember"
|
||||
case .apiJoinGroup: return "apiJoinGroup"
|
||||
@@ -682,8 +682,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case callInvitations(callInvitations: [RcvCallInvitation])
|
||||
case ntfTokenStatus(status: NtfTknStatus)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
|
||||
case ntfConns(ntfConns: [NtfConn])
|
||||
case connNtfMessages(receivedMsgs: [NtfMsgInfo?])
|
||||
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, expectedMsg_: NtfMsgInfo?, receivedMsg_: NtfMsgInfo?)
|
||||
case connNtfMessage(receivedMsg_: NtfMsgInfo?)
|
||||
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
case contactDisabled(user: UserRef, contact: Contact)
|
||||
@@ -851,8 +851,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .callInvitations: return "callInvitations"
|
||||
case .ntfTokenStatus: return "ntfTokenStatus"
|
||||
case .ntfToken: return "ntfToken"
|
||||
case .ntfConns: return "ntfConns"
|
||||
case .connNtfMessages: return "connNtfMessages"
|
||||
case .ntfMessages: return "ntfMessages"
|
||||
case .connNtfMessage: return "connNtfMessage"
|
||||
case .ntfMessage: return "ntfMessage"
|
||||
case .contactConnectionDeleted: return "contactConnectionDeleted"
|
||||
case .contactDisabled: return "contactDisabled"
|
||||
@@ -1029,8 +1029,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .callInvitations(invs): return String(describing: invs)
|
||||
case let .ntfTokenStatus(status): return String(describing: status)
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
|
||||
case let .ntfConns(ntfConns): return String(describing: ntfConns)
|
||||
case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))"
|
||||
case let .ntfMessages(u, connEntity, expectedMsg_, receivedMsg_): return withUser(u, "connEntity: \(String(describing: connEntity))\nexpectedMsg_: \(String(describing: expectedMsg_))\nreceivedMsg_: \(String(describing: receivedMsg_))")
|
||||
case let .connNtfMessage(receivedMsg_): return "receivedMsg_: \(String(describing: receivedMsg_))"
|
||||
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
|
||||
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .contactDisabled(u, contact): return withUser(u, String(describing: contact))
|
||||
@@ -2088,7 +2088,6 @@ public enum ProtocolErrorType: Decodable, Hashable {
|
||||
case AUTH
|
||||
case CRYPTO
|
||||
case QUOTA
|
||||
case STORE(storeErr: String)
|
||||
case NO_MSG
|
||||
case LARGE_MSG
|
||||
case EXPIRED
|
||||
|
||||
@@ -2270,13 +2270,16 @@ public enum ConnectionEntity: Decodable, Hashable {
|
||||
case let .userContactConnection(entityConnection, _): entityConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct NtfConn: Decodable, Hashable {
|
||||
public var user_: User?
|
||||
public var connEntity_: ConnectionEntity?
|
||||
public var expectedMsg_: NtfMsgInfo?
|
||||
|
||||
public var ntfsEnabled: Bool {
|
||||
switch self {
|
||||
case let .rcvDirectMsgConnection(_, contact): return contact?.chatSettings.enableNtfs == .all
|
||||
case let .rcvGroupMsgConnection(_, groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
|
||||
case .sndFileConnection: return false
|
||||
case .rcvFileConnection: return false
|
||||
case let .userContactConnection(_, userContact): return userContact.groupId == nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct NtfMsgInfo: Decodable, Hashable {
|
||||
|
||||
@@ -15,14 +15,13 @@ public let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
|
||||
public let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
|
||||
public let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
|
||||
public let ntfCategoryConnectionEvent = "NTF_CAT_CONNECTION_EVENT"
|
||||
public let ntfCategoryManyEvents = "NTF_CAT_MANY_EVENTS"
|
||||
public let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
|
||||
|
||||
public let appNotificationId = "chat.simplex.app.notification"
|
||||
|
||||
let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification")
|
||||
|
||||
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryContactRequest,
|
||||
@@ -35,12 +34,11 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User
|
||||
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName
|
||||
),
|
||||
targetContentIdentifier: nil,
|
||||
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId],
|
||||
badgeCount: badgeCount
|
||||
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId]
|
||||
)
|
||||
}
|
||||
|
||||
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryContactConnected,
|
||||
@@ -53,13 +51,12 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact,
|
||||
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName
|
||||
),
|
||||
targetContentIdentifier: contact.id,
|
||||
userInfo: ["userId": user.userId],
|
||||
userInfo: ["userId": user.userId]
|
||||
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
|
||||
badgeCount: badgeCount
|
||||
)
|
||||
}
|
||||
|
||||
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
|
||||
let previewMode = ntfPreviewModeGroupDefault.get()
|
||||
var title: String
|
||||
if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir {
|
||||
@@ -72,13 +69,12 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
|
||||
title: title,
|
||||
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
|
||||
targetContentIdentifier: cInfo.id,
|
||||
userInfo: ["userId": user.userId],
|
||||
userInfo: ["userId": user.userId]
|
||||
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
|
||||
badgeCount: badgeCount
|
||||
)
|
||||
}
|
||||
|
||||
public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutableNotificationContent {
|
||||
let text = invitation.callType.media == .video
|
||||
? NSLocalizedString("Incoming video call", comment: "notification")
|
||||
: NSLocalizedString("Incoming audio call", comment: "notification")
|
||||
@@ -88,12 +84,11 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCoun
|
||||
title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):",
|
||||
body: text,
|
||||
targetContentIdentifier: nil,
|
||||
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId],
|
||||
badgeCount: badgeCount
|
||||
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId]
|
||||
)
|
||||
}
|
||||
|
||||
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
var title: String
|
||||
var body: String? = nil
|
||||
@@ -123,12 +118,11 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit
|
||||
title: title,
|
||||
body: body,
|
||||
targetContentIdentifier: targetContentIdentifier,
|
||||
userInfo: ["userId": user.userId],
|
||||
badgeCount: badgeCount
|
||||
userInfo: ["userId": user.userId]
|
||||
)
|
||||
}
|
||||
|
||||
public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificationContent {
|
||||
var title: String
|
||||
switch dbStatus {
|
||||
case .errorNotADatabase:
|
||||
@@ -148,16 +142,14 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) ->
|
||||
}
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryConnectionEvent,
|
||||
title: title,
|
||||
badgeCount: badgeCount
|
||||
title: title
|
||||
)
|
||||
}
|
||||
|
||||
public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
public func createAppStoppedNtf() -> UNMutableNotificationContent {
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryConnectionEvent,
|
||||
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification"),
|
||||
badgeCount: badgeCount
|
||||
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,15 +159,8 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember
|
||||
: "#\(groupInfo.displayName) \(groupMember.chatViewName):"
|
||||
}
|
||||
|
||||
public func createNotification(
|
||||
categoryIdentifier: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
body: String? = nil,
|
||||
targetContentIdentifier: String? = nil,
|
||||
userInfo: [AnyHashable : Any] = [:],
|
||||
badgeCount: Int
|
||||
) -> UNMutableNotificationContent {
|
||||
public func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
|
||||
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.categoryIdentifier = categoryIdentifier
|
||||
content.title = title
|
||||
@@ -185,7 +170,6 @@ public func createNotification(
|
||||
content.userInfo = userInfo
|
||||
// TODO move logic of adding sound here, so it applies to background notifications too
|
||||
content.sound = .default
|
||||
content.badge = badgeCount as NSNumber
|
||||
// content.interruptionLevel = .active
|
||||
// content.relevanceScore = 0.5 // 0-1
|
||||
return content
|
||||
|
||||
@@ -4,10 +4,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.model.NtfManager
|
||||
@@ -15,6 +13,7 @@ import chat.simplex.app.model.NtfManager.getUserIdFromIntent
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -25,21 +24,13 @@ import kotlinx.coroutines.*
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
const val OLD_ANDROID_UI_FLAGS = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
mainActivity = WeakReference(this)
|
||||
platform.androidSetNightModeIfSupported()
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
|
||||
applyAppLocale(ChatModel.controller.appPrefs.appLanguage)
|
||||
// This flag makes status bar and navigation bar fully transparent. But on API level < 30 it breaks insets entirely
|
||||
// https://issuetracker.google.com/issues/236862874
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
// testJson()
|
||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||
@@ -56,7 +47,6 @@ class MainActivity: FragmentActivity() {
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AppScreen()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import chat.simplex.common.platform.Log
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.*
|
||||
import android.view.View
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -17,7 +16,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.MainActivity.Companion.OLD_ANDROID_UI_FLAGS
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.AcceptCallAction
|
||||
import chat.simplex.app.views.call.CallActivity
|
||||
@@ -28,6 +26,7 @@ import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chatlist.statusBarColorAfterCall
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
@@ -275,32 +274,79 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
|
||||
override fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean, themeBackgroundColor: Color) {
|
||||
override fun androidSetDrawerStatusAndNavBarColor(
|
||||
isLight: Boolean,
|
||||
drawerShadingColor: Color,
|
||||
toolbarOnTop: Boolean,
|
||||
navBarColor: Color,
|
||||
) {
|
||||
val window = mainActivity.get()?.window ?: return
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
|
||||
// Blend status bar color to the animated color
|
||||
val colors = CurrentColors.value.colors
|
||||
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
|
||||
var statusBar = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
|
||||
var statusBarLight = isLight
|
||||
|
||||
// SimplexGreen while in call
|
||||
if (window.statusBarColor == SimplexGreen.toArgb()) {
|
||||
statusBarColorAfterCall.intValue = statusBar
|
||||
statusBar = SimplexGreen.toArgb()
|
||||
statusBarLight = false
|
||||
}
|
||||
window.statusBarColor = statusBar
|
||||
val navBar = navBarColor.toArgb()
|
||||
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
|
||||
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
|
||||
}
|
||||
if (window.navigationBarColor != navBar) {
|
||||
window.navigationBarColor = navBar
|
||||
}
|
||||
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
|
||||
windowInsetController?.isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
|
||||
val window = mainActivity.get()?.window ?: return
|
||||
@Suppress("DEPRECATION")
|
||||
val statusLight = isLightStatusBar && chatModel.activeCall.value == null
|
||||
val navBarLight = isLightNavBar || windowOrientation() == WindowOrientation.LANDSCAPE
|
||||
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
|
||||
if (windowInsetController?.isAppearanceLightStatusBars != statusLight) {
|
||||
windowInsetController?.isAppearanceLightStatusBars = statusLight
|
||||
}
|
||||
window.navigationBarColor = Color.Transparent.toArgb()
|
||||
if (windowInsetController?.isAppearanceLightNavigationBars != navBarLight) {
|
||||
windowInsetController?.isAppearanceLightNavigationBars = navBarLight
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
window.decorView.systemUiVisibility = if (statusLight && navBarLight) {
|
||||
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
|
||||
} else if (statusLight) {
|
||||
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or OLD_ANDROID_UI_FLAGS
|
||||
} else if (navBarLight) {
|
||||
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
|
||||
} else {
|
||||
OLD_ANDROID_UI_FLAGS
|
||||
}
|
||||
window.navigationBarColor = if (blackNavBar) Color.Black.toArgb() else themeBackgroundColor.toArgb()
|
||||
|
||||
var statusBar = (if (hasTop && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
|
||||
} else {
|
||||
window.navigationBarColor = Color.Transparent.toArgb()
|
||||
if (CurrentColors.value.base == DefaultTheme.SIMPLEX) {
|
||||
backgroundColor.lighter(0.4f)
|
||||
} else {
|
||||
backgroundColor
|
||||
}
|
||||
}).toArgb()
|
||||
var statusBarLight = isLight
|
||||
|
||||
// SimplexGreen while in call
|
||||
if (window.statusBarColor == SimplexGreen.toArgb()) {
|
||||
statusBarColorAfterCall.intValue = statusBar
|
||||
statusBar = SimplexGreen.toArgb()
|
||||
statusBarLight = false
|
||||
}
|
||||
val navBar = (if (hasBottom && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
|
||||
} else {
|
||||
backgroundColor
|
||||
}).toArgb()
|
||||
if (window.statusBarColor != statusBar) {
|
||||
window.statusBarColor = statusBar
|
||||
}
|
||||
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
|
||||
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
|
||||
}
|
||||
if (window.navigationBarColor != navBar) {
|
||||
window.navigationBarColor = navBar
|
||||
}
|
||||
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
|
||||
windowInsetController?.isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +401,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ kotlin {
|
||||
implementation("androidx.activity:activity-compose:1.9.1")
|
||||
val workVersion = "2.9.1"
|
||||
implementation("androidx.work:work-runtime-ktx:$workVersion")
|
||||
implementation("com.google.accompanist:accompanist-insets:0.30.1")
|
||||
|
||||
// Video support
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.19.1")
|
||||
|
||||
+13
-1
@@ -3,13 +3,25 @@ package chat.simplex.common.platform
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import java.io.File
|
||||
|
||||
actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding()
|
||||
|
||||
@Composable
|
||||
actual fun ProvideWindowInsets(
|
||||
consumeWindowInsets: Boolean,
|
||||
windowInsetsAnimationsEnabled: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
com.google.accompanist.insets.ProvideWindowInsets(content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.desktopOnExternalDrag(
|
||||
enabled: Boolean,
|
||||
onFiles: (List<File>) -> Unit,
|
||||
onImage: (File) -> Unit,
|
||||
onImage: (Painter) -> Unit,
|
||||
onText: (String) -> Unit
|
||||
): Modifier = this
|
||||
|
||||
|
||||
+5
-16
@@ -6,7 +6,8 @@ import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.view.OnReceiveContentListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
@@ -25,7 +26,6 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
@@ -94,8 +94,8 @@ actual fun PlatformTextField(
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
AndroidView(modifier = Modifier, factory = { context ->
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) {
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: OnReceiveContentListener?
|
||||
@@ -140,13 +140,6 @@ actual fun PlatformTextField(
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
// shows keyboard when user had search field on ChatView focused before clicking on this text field
|
||||
// it still produce weird animation of closing/opening keyboard but the solution is to replace this Android EditText with Compose BasicTextField
|
||||
if (hasFocus) {
|
||||
showKeyboard = true
|
||||
}
|
||||
}
|
||||
editText.doOnTextChanged { text, _, _, _ ->
|
||||
if (!composeState.value.inProgress) {
|
||||
onMessageChange(text.toString())
|
||||
@@ -155,12 +148,8 @@ actual fun PlatformTextField(
|
||||
}
|
||||
}
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
val workaround = WorkaroundFocusSearchLayout(context)
|
||||
workaround.addView(editText)
|
||||
workaround.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
workaround
|
||||
editText
|
||||
}) {
|
||||
val it = it.children.first() as EditText
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.setHintTextColor(hintColor.toArgb())
|
||||
it.hint = placeholder
|
||||
|
||||
+3
-7
@@ -6,11 +6,11 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.text.BidiFormatter
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -50,11 +50,7 @@ actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.re
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun windowWidth(): Dp {
|
||||
val direction = LocalLayoutDirection.current
|
||||
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
||||
return LocalConfiguration.current.screenWidthDp.dp - cutout.calculateStartPadding(direction) - cutout.calculateEndPadding(direction)
|
||||
}
|
||||
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
|
||||
|
||||
@Composable
|
||||
actual fun windowHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp
|
||||
|
||||
+30
-129
@@ -7,12 +7,10 @@ import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.chatlist.NavigationBarBackground
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlin.math.absoluteValue
|
||||
@@ -27,74 +25,25 @@ actual fun LazyColumnWithScrollBar(
|
||||
horizontalAlignment: Alignment.Horizontal,
|
||||
flingBehavior: FlingBehavior,
|
||||
userScrollEnabled: Boolean,
|
||||
additionalBarOffset: State<Dp>?,
|
||||
fillMaxSize: Boolean,
|
||||
content: LazyListScope.() -> Unit
|
||||
) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
require(handler != null) { "Using LazyColumnWithScrollBar and without AppBarHandler is an error. Use LazyColumnWithScrollBarNoAppBar instead" }
|
||||
|
||||
val state = state ?: handler.listState
|
||||
val connection = handler.connection
|
||||
val state = state ?: LocalAppBarHandler.current?.listState ?: rememberLazyListState()
|
||||
val connection = LocalAppBarHandler.current?.connection
|
||||
LaunchedEffect(Unit) {
|
||||
if (reverseLayout) {
|
||||
snapshotFlow { state.layoutInfo.visibleItemsInfo.lastOrNull()?.offset ?: 0 }
|
||||
.collect { scrollPosition ->
|
||||
connection.appBarOffset = if (state.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.layoutInfo.totalItemsCount - 1) {
|
||||
state.layoutInfo.viewportEndOffset - scrollPosition.toFloat() - state.layoutInfo.afterContentPadding
|
||||
} else {
|
||||
// show always when last item is not visible
|
||||
-1000f
|
||||
}
|
||||
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
|
||||
snapshotFlow { state.firstVisibleItemScrollOffset }
|
||||
.filter { state.firstVisibleItemIndex == 0 }
|
||||
.collect { scrollPosition ->
|
||||
val offset = connection?.appBarOffset
|
||||
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
|
||||
connection.appBarOffset = -scrollPosition.toFloat()
|
||||
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
|
||||
}
|
||||
} else {
|
||||
snapshotFlow { state.firstVisibleItemScrollOffset }
|
||||
.filter { state.firstVisibleItemIndex == 0 }
|
||||
.collect { scrollPosition ->
|
||||
val offset = connection.appBarOffset
|
||||
if ((offset + scrollPosition + state.layoutInfo.afterContentPadding).absoluteValue > 1) {
|
||||
connection.appBarOffset = -scrollPosition.toFloat()
|
||||
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
if (fillMaxSize) {
|
||||
Modifier.fillMaxSize().copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
|
||||
} else {
|
||||
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
|
||||
},
|
||||
state,
|
||||
contentPadding,
|
||||
reverseLayout,
|
||||
verticalArrangement,
|
||||
horizontalAlignment,
|
||||
flingBehavior,
|
||||
userScrollEnabled
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
actual fun LazyColumnWithScrollBarNoAppBar(
|
||||
modifier: Modifier,
|
||||
state: LazyListState?,
|
||||
contentPadding: PaddingValues,
|
||||
reverseLayout: Boolean,
|
||||
verticalArrangement: Arrangement.Vertical,
|
||||
horizontalAlignment: Alignment.Horizontal,
|
||||
flingBehavior: FlingBehavior,
|
||||
userScrollEnabled: Boolean,
|
||||
additionalBarOffset: State<Dp>?,
|
||||
content: LazyListScope.() -> Unit
|
||||
) {
|
||||
val state = state ?: rememberLazyListState()
|
||||
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) {
|
||||
content()
|
||||
if (connection != null) {
|
||||
LazyColumn(modifier.nestedScroll(connection), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
|
||||
} else {
|
||||
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,80 +54,32 @@ actual fun ColumnWithScrollBar(
|
||||
horizontalAlignment: Alignment.Horizontal,
|
||||
state: ScrollState?,
|
||||
maxIntrinsicSize: Boolean,
|
||||
fillMaxSize: Boolean,
|
||||
content: @Composable() (ColumnScope.() -> Unit)
|
||||
) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
require(handler != null) { "Using ColumnWithScrollBar and without AppBarHandler is an error. Use ColumnWithScrollBarNoAppBar instead" }
|
||||
|
||||
val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier).imePadding() else modifier.imePadding()
|
||||
val state = state ?: handler.scrollState
|
||||
val connection = handler.connection
|
||||
val state = state ?: LocalAppBarHandler.current?.scrollState ?: rememberScrollState()
|
||||
val connection = LocalAppBarHandler.current?.connection
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { state.value }
|
||||
.collect { scrollPosition ->
|
||||
val offset = connection.appBarOffset
|
||||
if ((offset + scrollPosition).absoluteValue > 1) {
|
||||
val offset = connection?.appBarOffset
|
||||
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
|
||||
connection.appBarOffset = -scrollPosition.toFloat()
|
||||
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
if (maxIntrinsicSize) {
|
||||
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
|
||||
} else {
|
||||
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state)
|
||||
}, verticalArrangement, horizontalAlignment
|
||||
) {
|
||||
if (oneHandUI.value) {
|
||||
Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp).windowInsetsTopHeight(WindowInsets.statusBars))
|
||||
content()
|
||||
Spacer(Modifier.navigationBarsPadding().padding(bottom = AppBarHeight * fontSizeSqrtMultiplier))
|
||||
} else {
|
||||
Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier))
|
||||
content()
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value) {
|
||||
NavigationBarBackground(false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ColumnWithScrollBarNoAppBar(
|
||||
modifier: Modifier,
|
||||
verticalArrangement: Arrangement.Vertical,
|
||||
horizontalAlignment: Alignment.Horizontal,
|
||||
state: ScrollState?,
|
||||
maxIntrinsicSize: Boolean,
|
||||
content: @Composable() (ColumnScope.() -> Unit)
|
||||
) {
|
||||
val modifier = modifier.imePadding()
|
||||
val state = state ?: rememberScrollState()
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
if (maxIntrinsicSize) {
|
||||
if (connection != null) {
|
||||
Column(
|
||||
if (maxIntrinsicSize) {
|
||||
modifier.nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
|
||||
} else {
|
||||
modifier.nestedScroll(connection).verticalScroll(state)
|
||||
}, verticalArrangement, horizontalAlignment, content)
|
||||
} else {
|
||||
Column(if (maxIntrinsicSize) {
|
||||
modifier.verticalScroll(state).height(IntrinsicSize.Max)
|
||||
} else {
|
||||
modifier.verticalScroll(state)
|
||||
}, verticalArrangement, horizontalAlignment
|
||||
) {
|
||||
if (oneHandUI.value) {
|
||||
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
|
||||
content()
|
||||
} else {
|
||||
content()
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value) {
|
||||
NavigationBarBackground(false, false)
|
||||
}
|
||||
}, verticalArrangement, horizontalAlignment, content)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-8
@@ -3,18 +3,17 @@ package chat.simplex.common.platform
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Rect
|
||||
import android.os.*
|
||||
import android.view.*
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.*
|
||||
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.ui.theme.SimpleXTheme
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.ui.platform.LocalContext as LocalContext1
|
||||
import chat.simplex.res.MR
|
||||
@@ -44,13 +43,28 @@ actual fun LocalMultiplatformView(): Any? = LocalView.current
|
||||
|
||||
@Composable
|
||||
actual fun getKeyboardState(): State<KeyboardState> {
|
||||
val density = LocalDensity.current
|
||||
val ime = WindowInsets.ime
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
if (ime.getBottom(density) == 0) KeyboardState.Closed else KeyboardState.Opened
|
||||
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
KeyboardState.Opened
|
||||
} else {
|
||||
KeyboardState.Closed
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
onDispose {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
|
||||
}
|
||||
}
|
||||
|
||||
return keyboardState
|
||||
}
|
||||
|
||||
actual fun hideKeyboard(view: Any?, clearFocus: Boolean) {
|
||||
|
||||
+9
-14
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
@@ -47,7 +48,6 @@ import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.res.MR
|
||||
import com.google.accompanist.permissions.*
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
@@ -329,14 +329,11 @@ private fun ActiveCallOverlayLayout(
|
||||
flipCamera: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
CallAppBar(
|
||||
title = {
|
||||
if (call.hasVideo) {
|
||||
Text(call.contact.chatViewName, Modifier.offset(x = (-4).dp).padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
}
|
||||
},
|
||||
onBack = { chatModel.activeCallViewIsCollapsed.value = true }
|
||||
)
|
||||
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
|
||||
if (call.hasVideo) {
|
||||
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
@Composable
|
||||
fun SelectSoundDevice(size: Dp) {
|
||||
@@ -414,7 +411,6 @@ private fun ControlButton(icon: Painter, iconText: StringResource, enabled: Bool
|
||||
|
||||
@Composable
|
||||
private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, content: @Composable () -> Unit) {
|
||||
val ripple = remember { ripple(bounded = false, radius = size / 2, color = background.lighter(0.1f)) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(background, CircleShape)
|
||||
@@ -423,7 +419,7 @@ private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, backg
|
||||
onClick = action,
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple,
|
||||
indication = rememberRipple(bounded = false, radius = size / 2, color = background.lighter(0.1f)),
|
||||
enabled = enabled
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -594,9 +590,8 @@ fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Uni
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalView(background = Color.Black, showAppBar = false, close = {}) {
|
||||
Column {
|
||||
Spacer(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
|
||||
ModalView(background = Color.Black, showClose = false, close = {}) {
|
||||
ColumnWithScrollBar(Modifier.fillMaxSize()) {
|
||||
AppBarTitle(stringResource(MR.strings.permissions_required))
|
||||
Spacer(Modifier.weight(1f))
|
||||
val onClick = {
|
||||
|
||||
+9
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import chat.simplex.common.model.CIFile
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.helpers.ModalManager
|
||||
@@ -38,6 +39,14 @@ actual fun SimpleAndAnimatedImageView(
|
||||
if (getLoadedFilePath(file) != null) {
|
||||
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
if (smallView) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-11
@@ -1,26 +1,32 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
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.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
|
||||
import chat.simplex.common.model.durationText
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
|
||||
@@ -32,12 +38,11 @@ private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM
|
||||
@Composable
|
||||
actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
val onClick = { platform.androidStartCallActivity(false) }
|
||||
val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT + statusBar)) {
|
||||
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val ripple = remember { ripple(bounded = true, 3000.dp) }
|
||||
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT + statusBar).clickable(onClick = onClick, indication = ripple, interactionSource = source)) {
|
||||
GreenLine(statusBar, call)
|
||||
val indication = rememberRipple(bounded = true, 3000.dp)
|
||||
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
|
||||
GreenLine(call)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
@@ -45,7 +50,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
.size(CALL_BOTTOM_ICON_HEIGHT)
|
||||
.background(SimplexGreen, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick, indication = ripple, interactionSource = source)
|
||||
.clickable(onClick = onClick, indication = indication, interactionSource = source)
|
||||
.align(Alignment.BottomCenter),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -58,13 +63,16 @@ actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary solution for storing a color that needs to be applied after call ends
|
||||
var statusBarColorAfterCall = mutableIntStateOf(CurrentColors.value.colors.background.toArgb())
|
||||
|
||||
@Composable
|
||||
private fun GreenLine(statusBarHeight: Dp, call: Call) {
|
||||
private fun GreenLine(call: Call) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(SimplexGreen)
|
||||
.padding(top = -CALL_TOP_OFFSET + statusBarHeight)
|
||||
.padding(top = -CALL_TOP_OFFSET)
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -73,10 +81,12 @@ private fun GreenLine(statusBarHeight: Dp, call: Call) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
CallDuration(call)
|
||||
}
|
||||
val window = (LocalContext.current as Activity).window
|
||||
DisposableEffect(Unit) {
|
||||
platform.androidSetStatusAndNavigationBarAppearance(false, CurrentColors.value.colors.isLight)
|
||||
statusBarColorAfterCall.intValue = window.statusBarColor
|
||||
window.statusBarColor = SimplexGreen.toArgb()
|
||||
onDispose {
|
||||
platform.androidSetStatusAndNavigationBarAppearance(CurrentColors.value.colors.isLight, CurrentColors.value.colors.isLight)
|
||||
window.statusBarColor = statusBarColorAfterCall.intValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-59
@@ -19,11 +19,13 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.model.UserInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -34,7 +36,6 @@ private val USER_PICKER_ROW_PADDING = 16.dp
|
||||
@Composable
|
||||
actual fun UserPickerUsersSection(
|
||||
users: List<UserInfo>,
|
||||
iconColor: Color,
|
||||
stopped: Boolean,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
) {
|
||||
@@ -139,73 +140,87 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(clickableModifier)
|
||||
.drawBehind {
|
||||
val pos = when {
|
||||
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
|
||||
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
|
||||
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
|
||||
else -> 1 - dismissState.progress.fraction
|
||||
}
|
||||
val colors = CurrentColors.value.colors
|
||||
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
|
||||
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
|
||||
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
|
||||
|
||||
if (pickerState.value.isVisible()) {
|
||||
platform.androidSetDrawerStatusAndNavBarColor(
|
||||
isLight = colors.isLight,
|
||||
drawerShadingColor = shadingColor,
|
||||
toolbarOnTop = !appPrefs.oneHandUI.get(),
|
||||
navBarColor = colors.background.mixWith(colors.onBackground, 1 - userPickerAlpha())
|
||||
)
|
||||
} else if (ModalManager.start.modalCount.value == 0) {
|
||||
platform.androidSetDrawerStatusAndNavBarColor(
|
||||
isLight = colors.isLight,
|
||||
drawerShadingColor = shadingColor,
|
||||
toolbarOnTop = !appPrefs.oneHandUI.get(),
|
||||
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
|
||||
} else {
|
||||
colors.background
|
||||
})
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
if (pos != 0f) resultingColor else Color.Transparent,
|
||||
alpha = calculateFraction(pos = pos)
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
if (heightValue == 0) {
|
||||
alpha = 0f
|
||||
}
|
||||
translationY = dismissState.offset.value
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(clickableModifier)
|
||||
.drawBehind {
|
||||
val pos = calculatePosition(dismissState)
|
||||
val colors = CurrentColors.value.colors
|
||||
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
|
||||
drawRect(
|
||||
if (pos != 0f) resultingColor else Color.Transparent,
|
||||
alpha = calculateFraction(pos = pos)
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
if (heightValue == 0) {
|
||||
alpha = 0f
|
||||
}
|
||||
translationY = dismissState.offset.value
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
Modifier.onSizeChanged { height.intValue = it.height }
|
||||
) {
|
||||
Box(
|
||||
Modifier.onSizeChanged { height.intValue = it.height }
|
||||
) {
|
||||
KeyChangeEffect(pickerIsVisible) {
|
||||
if (pickerState.value.isVisible()) {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
KeyChangeEffect(pickerIsVisible) {
|
||||
if (pickerState.value.isVisible()) {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
val draggableModifier = if (height.intValue != 0)
|
||||
Modifier.draggableBottomDrawerModifier(
|
||||
state = dismissState,
|
||||
swipeDistance = height.intValue.toFloat(),
|
||||
)
|
||||
else Modifier
|
||||
Box(draggableModifier.then(modifier).navigationBarsPadding()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
val draggableModifier = if (height.intValue != 0)
|
||||
Modifier.draggableBottomDrawerModifier(
|
||||
state = dismissState,
|
||||
swipeDistance = height.intValue.toFloat(),
|
||||
)
|
||||
else Modifier
|
||||
Box(draggableModifier.then(modifier)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
NavigationBarBackground(
|
||||
modifier = Modifier.graphicsLayer { alpha = if (calculatePosition(dismissState) > 0.1f) 1f else 0f },
|
||||
color = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePosition(dismissState: DismissState): Float = when {
|
||||
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
|
||||
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
|
||||
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
|
||||
else -> 1 - dismissState.progress.fraction
|
||||
}
|
||||
|
||||
private fun Modifier.draggableBottomDrawerModifier(
|
||||
state: DismissState,
|
||||
swipeDistance: Float,
|
||||
|
||||
-2
@@ -171,8 +171,6 @@ actual fun GetImageBottomSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hideBottomSheet()
|
||||
}
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
|
||||
/**
|
||||
* A workaround for the ANR issue on Compose 1.7.x.
|
||||
* https://issuetracker.google.com/issues/369354336
|
||||
* Code from:
|
||||
* https://issuetracker.google.com/issues/369354336#comment8
|
||||
*/
|
||||
class WorkaroundFocusSearchLayout : FrameLayout {
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
) : super(context)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
) : super(context, attrs)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
) : super(context, attrs, defStyleAttr)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
override fun focusSearch(focused: View?, direction: Int): View? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -2,7 +2,6 @@ package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
@@ -32,7 +31,6 @@ import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.saveAppLocale
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -77,7 +75,9 @@ fun AppearanceScope.AppearanceLayout(
|
||||
systemDarkTheme: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.appearance_settings))
|
||||
SectionView(stringResource(MR.strings.settings_section_title_interface), contentPadding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
@@ -106,15 +106,15 @@ fun AppearanceScope.AppearanceLayout(
|
||||
}
|
||||
// }
|
||||
|
||||
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI)
|
||||
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) {
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
ThemesSection(systemDarkTheme)
|
||||
|
||||
SectionDividerSpaced()
|
||||
AppToolbarsSection()
|
||||
|
||||
SectionDividerSpaced()
|
||||
MessageShapeSection()
|
||||
|
||||
|
||||
+2
-1
@@ -13,13 +13,14 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
@Composable
|
||||
actual fun SettingsSectionApp(
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.settings_section_title_app)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp)
|
||||
SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,10 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
||||
import chat.simplex.common.model.*
|
||||
@@ -42,39 +39,14 @@ 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() {
|
||||
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
|
||||
SimpleXTheme {
|
||||
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
|
||||
// This padding applies to landscape view only taking care of navigation bar and holes in screen in status bar area
|
||||
// (because nav bar and holes located on vertical sides of screen in landscape view)
|
||||
val direction = LocalLayoutDirection.current
|
||||
val safePadding = WindowInsets.safeDrawing.asPaddingValues()
|
||||
val cutout = WindowInsets.displayCutout.asPaddingValues()
|
||||
val cutoutStart = cutout.calculateStartPadding(direction)
|
||||
val cutoutEnd = cutout.calculateEndPadding(direction)
|
||||
val cutoutMax = maxOf(cutoutStart, cutoutEnd)
|
||||
val paddingStartUntouched = safePadding.calculateStartPadding(direction)
|
||||
val paddingStart = paddingStartUntouched - cutoutStart
|
||||
val paddingEndUntouched = safePadding.calculateEndPadding(direction)
|
||||
val paddingEnd = paddingEndUntouched - cutoutEnd
|
||||
// Such a strange layout is needed because the main content should be covered by solid color in order to hide overflow
|
||||
// of some elements that may have negative offset (so, can't use Row {}).
|
||||
// To check: go to developer settings of Android, choose Display cutout -> Punch hole, and rotate the phone to landscape, open any chat
|
||||
Box {
|
||||
val fullscreenGallery = remember { chatModel.fullscreenGalleryVisible }
|
||||
Box(Modifier.padding(start = paddingStart + cutoutMax, end = paddingEnd + cutoutMax).consumeWindowInsets(PaddingValues(start = paddingStartUntouched, end = paddingEndUntouched))) {
|
||||
Box(Modifier.drawBehind {
|
||||
if (fullscreenGallery.value) {
|
||||
drawRect(Color.Black, topLeft = Offset(-(paddingStart + cutoutMax).toPx(), 0f), Size(size.width + (paddingStart + cutoutMax).toPx() + (paddingEnd + cutoutMax).toPx(), size.height))
|
||||
}
|
||||
}) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,9 +138,7 @@ fun MainScreen() {
|
||||
}
|
||||
SetupClipboardListener()
|
||||
if (appPlatform.isAndroid) {
|
||||
AndroidWrapInCallLayout {
|
||||
AndroidScreen(userPickerState)
|
||||
}
|
||||
AndroidScreen(userPickerState)
|
||||
} else {
|
||||
DesktopScreen(userPickerState)
|
||||
}
|
||||
@@ -200,9 +170,7 @@ fun MainScreen() {
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
AndroidWrapInCallLayout {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
ModalManager.fullscreen.showInView()
|
||||
SwitchingUsersView()
|
||||
}
|
||||
|
||||
@@ -269,39 +237,19 @@ fun MainScreen() {
|
||||
|
||||
val ANDROID_CALL_TOP_PADDING = 40.dp
|
||||
|
||||
@Composable
|
||||
fun AndroidWrapInCallLayout(content: @Composable () -> Unit) {
|
||||
val call = remember { chatModel.activeCall}.value
|
||||
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
|
||||
Box {
|
||||
Box(Modifier.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)) {
|
||||
content()
|
||||
}
|
||||
if (call != null && showCallArea) {
|
||||
ActiveCallInteractiveArea(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
BoxWithConstraints {
|
||||
val call = remember { chatModel.activeCall} .value
|
||||
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
|
||||
val currentChatId = remember { mutableStateOf(chatModel.chatId.value) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
||||
val direction = LocalLayoutDirection.current
|
||||
val hasCutout = cutout.calculateStartPadding(direction) + cutout.calculateEndPadding(direction) > 0.dp
|
||||
Box(
|
||||
Modifier
|
||||
// clipping only for devices with cutout currently visible on sides. It prevents showing chat list with open chat view
|
||||
// In order cases it's not needed to use clip
|
||||
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
|
||||
.graphicsLayer {
|
||||
// minOf thing is needed for devices with holes in screen while the user on ChatView rotates his phone from portrait to landscape
|
||||
// because in this case (at least in emulator) maxWidth changes in two steps: big first, smaller on next frame.
|
||||
// But offset is remembered already, so this is a better way than dropping a value of offset
|
||||
translationX = -minOf(offset.value.dp, maxWidth).toPx()
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||
) {
|
||||
StartPartOfScreen(userPickerState)
|
||||
}
|
||||
@@ -323,40 +271,51 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it == null) onComposed(null)
|
||||
if (it == null) {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
|
||||
onComposed(null)
|
||||
}
|
||||
currentChatId.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { ModalManager.center.modalCount.value > 0 }
|
||||
.filter { chatModel.chatId.value == null }
|
||||
.collect { modalBackground ->
|
||||
if (chatModel.newChatSheetVisible.value) {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, appPrefs.oneHandUI.get())
|
||||
} else if (modalBackground) {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
|
||||
} else {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier
|
||||
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
|
||||
.graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() }
|
||||
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
|
||||
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||
) Box2@{
|
||||
currentChatId.value?.let {
|
||||
ChatView(currentChatId, onComposed)
|
||||
}
|
||||
}
|
||||
if (call != null && showCallArea) {
|
||||
ActiveCallInteractiveArea(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
if (chatModel.setDeliveryReceipts.value) {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
SetDeliveryReceiptsView(chatModel)
|
||||
}
|
||||
SetDeliveryReceiptsView(chatModel)
|
||||
} else {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null) {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
|
||||
}
|
||||
} else {
|
||||
// LALAL initial load of view doesn't show blur. Focusing text field shows it
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(keyboardCoversBar = false)) {
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
}
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +90,6 @@ object ChatModel {
|
||||
// Needed to check for bottom nav bar and to apply or not navigation bar color on Android
|
||||
val newChatSheetVisible = mutableStateOf(false)
|
||||
|
||||
// Needed to apply black color to left/right cutout area on Android
|
||||
val fullscreenGalleryVisible = mutableStateOf(false)
|
||||
|
||||
// preferences
|
||||
val notificationPreviewMode by lazy {
|
||||
mutableStateOf(
|
||||
|
||||
+1
-10
@@ -118,9 +118,6 @@ class AppPreferences {
|
||||
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
|
||||
val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true)
|
||||
val privacyMediaBlurRadius = mkIntPreference(SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS, 0)
|
||||
// Blur broken on Android 12, see https://github.com/chrisbanes/haze/issues/77. And not available before 12
|
||||
val deviceSupportsBlur = appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 32
|
||||
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
|
||||
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
|
||||
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
|
||||
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
|
||||
@@ -226,8 +223,6 @@ class AppPreferences {
|
||||
val chatItemTail = mkBoolPreference(SHARED_PREFS_CHAT_ITEM_TAIL, true)
|
||||
val fontScale = mkFloatPreference(SHARED_PREFS_FONT_SCALE, 1f)
|
||||
val densityScale = mkFloatPreference(SHARED_PREFS_DENSITY_SCALE, 1f)
|
||||
val inAppBarsDefaultAlpha = if (deviceSupportsBlur) 0.875f else 0.975f
|
||||
val inAppBarsAlpha = mkFloatPreference(SHARED_PREFS_IN_APP_BARS_ALPHA, inAppBarsDefaultAlpha)
|
||||
|
||||
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
|
||||
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
|
||||
@@ -249,7 +244,7 @@ class AppPreferences {
|
||||
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
|
||||
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
|
||||
|
||||
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
|
||||
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, appPlatform.isAndroid)
|
||||
|
||||
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
|
||||
laNoticeShown to false,
|
||||
@@ -367,7 +362,6 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
|
||||
private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays"
|
||||
private const val SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS = "PrivacyMediaBlurRadius"
|
||||
private const val SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS = "AppearanceBarsBlurRadius"
|
||||
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
|
||||
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
|
||||
@@ -434,7 +428,6 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_CHAT_ITEM_TAIL = "ChatItemTail"
|
||||
private const val SHARED_PREFS_FONT_SCALE = "FontScale"
|
||||
private const val SHARED_PREFS_DENSITY_SCALE = "DensityScale"
|
||||
private const val SHARED_PREFS_IN_APP_BARS_ALPHA = "InAppBarsAlpha"
|
||||
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
|
||||
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
|
||||
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
|
||||
@@ -6061,7 +6054,6 @@ sealed class SMPErrorType {
|
||||
is AUTH -> "AUTH"
|
||||
is CRYPTO -> "CRYPTO"
|
||||
is QUOTA -> "QUOTA"
|
||||
is STORE -> "STORE ${storeErr}"
|
||||
is NO_MSG -> "NO_MSG"
|
||||
is LARGE_MSG -> "LARGE_MSG"
|
||||
is EXPIRED -> "EXPIRED"
|
||||
@@ -6074,7 +6066,6 @@ sealed class SMPErrorType {
|
||||
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
|
||||
@Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType()
|
||||
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
|
||||
@Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType()
|
||||
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
|
||||
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
|
||||
@Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType()
|
||||
|
||||
+10
-1
@@ -14,11 +14,20 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import java.io.File
|
||||
|
||||
expect fun Modifier.navigationBarsWithImePadding(): Modifier
|
||||
|
||||
@Composable
|
||||
expect fun ProvideWindowInsets(
|
||||
consumeWindowInsets: Boolean = true,
|
||||
windowInsetsAnimationsEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun Modifier.desktopOnExternalDrag(
|
||||
enabled: Boolean = true,
|
||||
onFiles: (List<File>) -> Unit = {},
|
||||
onImage: (File) -> Unit = {},
|
||||
onImage: (Painter) -> Unit = {},
|
||||
onText: (String) -> Unit = {}
|
||||
): Modifier
|
||||
|
||||
|
||||
+2
-3
@@ -8,7 +8,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.common.model.ChatId
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
interface PlatformInterface {
|
||||
@@ -21,12 +20,12 @@ interface PlatformInterface {
|
||||
fun androidChatInitializedAndStarted() {}
|
||||
fun androidIsBackgroundCallAllowed(): Boolean = true
|
||||
fun androidSetNightModeIfSupported() {}
|
||||
fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean = false, themeBackgroundColor: Color = CurrentColors.value.colors.background) {}
|
||||
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
|
||||
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
|
||||
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
|
||||
fun androidPictureInPictureAllowed(): Boolean = true
|
||||
fun androidCallEnded() {}
|
||||
fun androidRestartNetworkObserver() {}
|
||||
val androidApiLevel: Int? get() = null
|
||||
@Composable fun androidLockPortraitOrientation() {}
|
||||
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
||||
@Composable fun desktopShowAppUpdateNotice() {}
|
||||
|
||||
-34
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -22,44 +21,11 @@ expect fun LazyColumnWithScrollBar(
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
additionalBarOffset: State<Dp>? = null,
|
||||
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
|
||||
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
|
||||
fillMaxSize: Boolean = true,
|
||||
content: LazyListScope.() -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun LazyColumnWithScrollBarNoAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
additionalBarOffset: State<Dp>? = null,
|
||||
content: LazyListScope.() -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun ColumnWithScrollBar(
|
||||
modifier: Modifier = Modifier,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
state: ScrollState? = null,
|
||||
// set true when you want to show something in the center with respected .fillMaxSize()
|
||||
maxIntrinsicSize: Boolean = false,
|
||||
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
|
||||
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
|
||||
fillMaxSize: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun ColumnWithScrollBarNoAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
|
||||
+15
-21
@@ -1,14 +1,14 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
@@ -587,27 +587,21 @@ data class ThemeModeOverride (
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState<IntSize>?, bgLayer: GraphicsLayer?/*, shape: Shape = RectangleShape*/): Modifier {
|
||||
return drawBehind {
|
||||
copyBackgroundToAppBar(bgLayerSize, bgLayer) {
|
||||
if (baseTheme == DefaultTheme.SIMPLEX) {
|
||||
drawRect(brush = themedBackgroundBrush())
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background)
|
||||
}
|
||||
}
|
||||
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
|
||||
return if (baseTheme == DefaultTheme.SIMPLEX) {
|
||||
this.background(brush = Brush.linearGradient(
|
||||
listOf(
|
||||
CurrentColors.value.colors.background.darker(0.4f),
|
||||
CurrentColors.value.colors.background.lighter(0.4f)
|
||||
),
|
||||
Offset(0f, Float.POSITIVE_INFINITY),
|
||||
Offset(Float.POSITIVE_INFINITY, 0f)
|
||||
), shape = shape)
|
||||
} else {
|
||||
this.background(color = CurrentColors.value.colors.background, shape = shape)
|
||||
}
|
||||
}
|
||||
|
||||
fun themedBackgroundBrush(): Brush = Brush.linearGradient(
|
||||
listOf(
|
||||
CurrentColors.value.colors.background.darker(0.4f),
|
||||
CurrentColors.value.colors.background.lighter(0.4f)
|
||||
),
|
||||
Offset(0f, Float.POSITIVE_INFINITY),
|
||||
Offset(Float.POSITIVE_INFINITY, 0f)
|
||||
)
|
||||
|
||||
val DEFAULT_PADDING = 20.dp
|
||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -106,7 +107,7 @@ object ThemeManager {
|
||||
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
|
||||
platform.androidSetNightModeIfSupported()
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !ChatController.appPrefs.oneHandUI.get(), ChatController.appPrefs.oneHandUI.get())
|
||||
}
|
||||
|
||||
fun changeDarkTheme(theme: String) {
|
||||
@@ -124,6 +125,10 @@ object ThemeManager {
|
||||
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||
appPrefs.currentThemeIds.set(themeIds)
|
||||
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
|
||||
if (name == ThemeColor.BACKGROUND) {
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyThemeColor(name: ThemeColor, color: Color? = null, pref: MutableState<ThemeModeOverride>) {
|
||||
|
||||
+73
-74
@@ -7,34 +7,40 @@ import androidx.compose.foundation.lazy.*
|
||||
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.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
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.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
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.collect
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TerminalView(floating: Boolean = false) {
|
||||
fun TerminalView(floating: Boolean = false, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
val close = {
|
||||
close()
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.center.closeModals()
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = {
|
||||
close()
|
||||
})
|
||||
TerminalLayout(
|
||||
composeState,
|
||||
floating,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,6 +69,7 @@ fun TerminalLayout(
|
||||
composeState: MutableState<ComposeState>,
|
||||
floating: Boolean,
|
||||
sendCommand: () -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
@@ -70,63 +77,65 @@ fun TerminalLayout(
|
||||
fun onMessageChange(s: String) {
|
||||
composeState.value = composeState.value.copy(message = s)
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val composeViewHeight = remember { mutableStateOf(0.dp) }
|
||||
AdaptingBottomPaddingLayout(Modifier, CONSOLE_COMPOSE_LAYOUT_ID, composeViewHeight) {
|
||||
TerminalLog(floating, composeViewHeight)
|
||||
Column(
|
||||
Modifier
|
||||
.layoutId(CONSOLE_COMPOSE_LAYOUT_ID)
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp))
|
||||
.imePadding()
|
||||
.padding(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp)
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
Divider()
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
isDirectChat = false,
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
sendMsgEnabled = true,
|
||||
sendButtonEnabled = true,
|
||||
nextSendGrpInv = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
placeholder = "",
|
||||
sendMessage = { sendCommand() },
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
editPrevMessage = {},
|
||||
onMessageChange = ::onMessageChange,
|
||||
onFilesPasted = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Scaffold(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Column {
|
||||
Divider()
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
isDirectChat = false,
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
sendMsgEnabled = true,
|
||||
sendButtonEnabled = true,
|
||||
nextSendGrpInv = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
placeholder = "",
|
||||
sendMessage = { sendCommand() },
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
editPrevMessage = {},
|
||||
onMessageChange = ::onMessageChange,
|
||||
onFilesPasted = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
contentColor = LocalContentColor.current,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colors.background,
|
||||
contentColor = LocalContentColor.current
|
||||
) {
|
||||
TerminalLog(floating)
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value) {
|
||||
NavigationBarBackground(true, oneHandUI.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
||||
fun TerminalLog(floating: Boolean) {
|
||||
val reversedTerminalItems by remember {
|
||||
derivedStateOf { chatModel.terminalItems.value.asReversed() }
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
|
||||
LaunchedEffect(Unit) {
|
||||
var autoScrollToBottom = listState.firstVisibleItemIndex <= 1
|
||||
var autoScrollToBottom = true
|
||||
launch {
|
||||
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||
.filter { autoScrollToBottom }
|
||||
@@ -141,21 +150,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
||||
launch {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.collect {
|
||||
autoScrollToBottom = it == 0
|
||||
autoScrollToBottom = listState.firstVisibleItemIndex == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumnWithScrollBar (
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
state = listState,
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
LazyColumnWithScrollBar(reverseLayout = true, state = listState) {
|
||||
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val rhId = item.remoteHostId
|
||||
val rhIdStr = if (rhId == null) "" else "$rhId "
|
||||
Text(
|
||||
@@ -172,15 +172,13 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
||||
ModalManager.start
|
||||
}
|
||||
modalPlace.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
|
||||
ColumnWithScrollBar {
|
||||
SelectionContainer {
|
||||
val details = item.details
|
||||
.let {
|
||||
if (it.length < 100_000) it
|
||||
else it.substring(0, 100_000)
|
||||
}
|
||||
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
val details = item.details
|
||||
.let {
|
||||
if (it.length < 100_000) it
|
||||
else it.substring(0, 100_000)
|
||||
}
|
||||
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
}.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
@@ -210,7 +208,8 @@ fun PreviewTerminalLayout() {
|
||||
TerminalLayout(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
|
||||
sendCommand = {},
|
||||
floating = false
|
||||
floating = false,
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+59
-37
@@ -40,6 +40,8 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -48,9 +50,11 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), withPadding = false, bottomPadding = DEFAULT_PADDING)
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.display_name),
|
||||
@@ -98,6 +102,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -106,42 +111,59 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
ModalView({
|
||||
if (chatModel.users.none { !it.user.hidden }) {
|
||||
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}) {
|
||||
ColumnWithScrollBar {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
|
||||
Box(Modifier.align(Alignment.CenterHorizontally)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
|
||||
val handler = remember { AppBarHandler() }
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides handler
|
||||
) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.themedBackground(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CloseSheetBar(close = {
|
||||
if (chatModel.users.none { !it.user.hidden }) {
|
||||
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.create_profile_button,
|
||||
onboarding = null,
|
||||
enabled = canCreateProfile(displayName.value),
|
||||
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
|
||||
)
|
||||
// Reserve space
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
})
|
||||
BackHandler(onBack = {
|
||||
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
})
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
ColumnWithScrollBar(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
|
||||
Box(Modifier.align(Alignment.CenterHorizontally)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
|
||||
}
|
||||
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.create_profile_button,
|
||||
onboarding = null,
|
||||
enabled = canCreateProfile(displayName.value),
|
||||
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
|
||||
)
|
||||
// Reserve space
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -233,6 +255,7 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 50.dp)
|
||||
.navigationBarsWithImePadding()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
Column(
|
||||
Modifier
|
||||
@@ -266,7 +289,6 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ fun IncomingCallAlertLayout(
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
|
||||
Column(Modifier.fillMaxWidth().background(color).statusBarsPadding().padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
|
||||
+4
-1
@@ -529,7 +529,10 @@ fun ChatInfoLayout(
|
||||
KeyChangeEffect(chat.id) {
|
||||
scope.launch { scrollState.scrollTo(0) }
|
||||
}
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
|
||||
+4
-4
@@ -276,7 +276,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
@Composable
|
||||
fun HistoryTab() {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
|
||||
Details()
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
|
||||
val versions = ciInfo.itemVersions
|
||||
@@ -300,7 +300,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
@Composable
|
||||
fun QuoteTab(qi: CIQuote) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
|
||||
Details()
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
|
||||
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
@@ -313,7 +313,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
@Composable
|
||||
fun ForwardedFromTab(forwardedFromItem: AChatItem) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
|
||||
Details()
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
|
||||
SectionView {
|
||||
@@ -375,7 +375,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
@Composable
|
||||
fun DeliveryTab(memberDeliveryStatuses: List<MemberDeliveryStatus>) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
|
||||
Details()
|
||||
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
|
||||
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
|
||||
|
||||
+216
-212
@@ -12,11 +12,10 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -109,7 +108,6 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) {
|
||||
when (chatInfo) {
|
||||
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
|
||||
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
|
||||
@@ -525,10 +523,28 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
|
||||
showSearch = showSearch
|
||||
)
|
||||
if (appPlatform.isAndroid) {
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
val backgroundColorState = rememberUpdatedState(backgroundColor)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { ModalManager.center.modalCount.value > 0 }
|
||||
.collect { modalBackground ->
|
||||
if (modalBackground) {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
|
||||
} else {
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, backgroundColorState.value, true, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatInfo.ContactConnection -> {
|
||||
val close = { chatModel.chatId.value = null }
|
||||
val handler = remember { AppBarHandler() }
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides handler
|
||||
) {
|
||||
ModalView(close, showClose = appPlatform.isAndroid, content = {
|
||||
ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close)
|
||||
})
|
||||
@@ -537,9 +553,14 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
ModalManager.end.closeModals()
|
||||
chatModel.chatItems.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatInfo.InvalidJSON -> {
|
||||
val close = { chatModel.chatId.value = null }
|
||||
val handler = remember { AppBarHandler() }
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides handler
|
||||
) {
|
||||
ModalView(close, showClose = appPlatform.isAndroid, endButtons = { ShareButton { clipboard.shareText(chatInfo.json) } }, content = {
|
||||
InvalidJSONView(chatInfo.json)
|
||||
})
|
||||
@@ -548,10 +569,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
ModalManager.end.closeModals()
|
||||
chatModel.chatItems.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,67 +642,81 @@ fun ChatLayout(
|
||||
.desktopOnExternalDrag(
|
||||
enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value,
|
||||
onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
|
||||
onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } },
|
||||
onImage = {
|
||||
// TODO: file is not saved anywhere?!
|
||||
val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
|
||||
tmpFile.deleteOnExit()
|
||||
chatModel.filesToDelete.add(tmpFile)
|
||||
val uri = tmpFile.toURI()
|
||||
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(uri), null) }
|
||||
},
|
||||
onText = {
|
||||
// Need to parse HTML in order to correctly display the content
|
||||
//composeState.value = composeState.value.copy(message = composeState.value.message + it)
|
||||
},
|
||||
)
|
||||
) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
sheetElevation = 0.dp,
|
||||
sheetContent = {
|
||||
ChooseAttachmentView(
|
||||
attachmentOption,
|
||||
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||
)
|
||||
},
|
||||
sheetState = attachmentBottomSheetState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
val composeViewHeight = remember { mutableStateOf(0.dp) }
|
||||
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
|
||||
val remoteHostId = remember { remoteHostId }.value
|
||||
val chatInfo = remember { chatInfo }.value
|
||||
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
|
||||
if (chatInfo != null) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetElevation = 0.dp,
|
||||
sheetContent = {
|
||||
ChooseAttachmentView(
|
||||
attachmentOption,
|
||||
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||
)
|
||||
},
|
||||
sheetState = attachmentBottomSheetState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
|
||||
val setFloatingButton = { button: @Composable () -> Unit ->
|
||||
floatingButton.value = button
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (selectedChatItems.value == null) {
|
||||
val chatInfo = chatInfo.value
|
||||
if (chatInfo != null) {
|
||||
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
|
||||
}
|
||||
} else {
|
||||
SelectedItemsTopToolbar(selectedChatItems)
|
||||
}
|
||||
},
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
contentColor = LocalContentColor.current,
|
||||
backgroundColor = Color.Unspecified
|
||||
) { contentPadding ->
|
||||
val wallpaperImage = MaterialTheme.wallpaper.type.image
|
||||
val wallpaperType = MaterialTheme.wallpaper.type
|
||||
val backgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, MaterialTheme.colors.background)
|
||||
val tintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.then(if (wallpaperImage != null)
|
||||
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor) }
|
||||
else
|
||||
Modifier)
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
val remoteHostId = remember { remoteHostId }.value
|
||||
val chatInfo = remember { chatInfo }.value
|
||||
if (chatInfo != null) {
|
||||
ChatItemsList(
|
||||
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
|
||||
remoteHostId, chatInfo, unreadCount, composeState, searchValue,
|
||||
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
|
||||
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
|
||||
)
|
||||
}
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(
|
||||
Modifier
|
||||
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.then(if (oneHandUI.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier)
|
||||
) {
|
||||
composeView()
|
||||
}
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
if (oneHandUI.value) {
|
||||
StatusBarBackground()
|
||||
} else {
|
||||
NavigationBarBackground(true, oneHandUI.value, noAlpha = true)
|
||||
}
|
||||
Box(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
|
||||
if (selectedChatItems.value == null) {
|
||||
if (chatInfo != null) {
|
||||
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
|
||||
}
|
||||
} else {
|
||||
SelectedItemsTopToolbar(selectedChatItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,7 +724,7 @@ fun ChatLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.ChatInfoToolbar(
|
||||
fun ChatInfoToolbar(
|
||||
chatInfo: ChatInfo,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
@@ -841,33 +876,21 @@ fun BoxScope.ChatInfoToolbar(
|
||||
}
|
||||
}
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
DefaultAppBar(
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
|
||||
title = { ChatInfoToolbarTitle(chatInfo) },
|
||||
onTitleClick = if (chatInfo is ChatInfo.Local) null else info,
|
||||
showSearch = showSearch.value,
|
||||
onTop = !oneHandUI.value,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = { barButtons.forEach { it() } }
|
||||
buttons = barButtons
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
|
||||
val density = LocalDensity.current
|
||||
val width = remember { mutableStateOf(250.dp) }
|
||||
val height = remember { mutableStateOf(0.dp) }
|
||||
DefaultDropdownMenu(
|
||||
showMenu,
|
||||
modifier = Modifier.onSizeChanged { with(density) {
|
||||
width.value = it.width.toDp().coerceAtLeast(250.dp)
|
||||
if (oneHandUI.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) height.value = it.height.toDp()
|
||||
} },
|
||||
offset = DpOffset(-width.value, if (oneHandUI.value) -height.value else AppBarHeight)
|
||||
) {
|
||||
if (oneHandUI.value) {
|
||||
menuItems.asReversed().forEach { it() }
|
||||
} else {
|
||||
menuItems.forEach { it() }
|
||||
}
|
||||
|
||||
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
|
||||
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight * fontSizeSqrtMultiplier)) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
menuItems.forEach { it() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -911,12 +934,11 @@ private fun ContactVerifiedShield() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.ChatItemsList(
|
||||
fun BoxWithConstraintsScope.ChatItemsList(
|
||||
remoteHostId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeViewHeight: State<Dp>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
@@ -941,6 +963,7 @@ fun BoxScope.ChatItemsList(
|
||||
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean
|
||||
@@ -964,18 +987,13 @@ fun BoxScope.ChatItemsList(
|
||||
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
|
||||
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() })
|
||||
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
|
||||
val scrollToItem: State<(Long) -> Unit> = remember {
|
||||
mutableStateOf(
|
||||
{ itemId: Long ->
|
||||
val index = reversedChatItems.value.indexOfFirst { it.id == itemId }
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) }
|
||||
}
|
||||
}
|
||||
)
|
||||
val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
|
||||
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
|
||||
val scrollToItem: (Long) -> Unit = { itemId: Long ->
|
||||
val index = reversedChatItems.indexOfFirst { it.id == itemId }
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||
}
|
||||
}
|
||||
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
|
||||
LaunchedEffect(chatInfo.id) {
|
||||
@@ -993,18 +1011,8 @@ fun BoxScope.ChatItemsList(
|
||||
VideoPlayerHolder.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumnWithScrollBar(
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
|
||||
val itemScope = rememberCoroutineScope()
|
||||
LazyColumnWithScrollBar(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
@@ -1012,10 +1020,10 @@ fun BoxScope.ChatItemsList(
|
||||
) {
|
||||
val provider = {
|
||||
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
|
||||
itemScope.launch {
|
||||
scope.launch {
|
||||
listState.scrollToItem(
|
||||
kotlin.math.min(reversedChatItems.value.lastIndex, indexInReversed + 1),
|
||||
-maxHeight.value
|
||||
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
|
||||
-maxHeightRounded
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1028,7 +1036,7 @@ fun BoxScope.ChatItemsList(
|
||||
tryOrShowError("${cItem.id}ChatItem", error = {
|
||||
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
}) {
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,7 +1044,7 @@ fun BoxScope.ChatItemsList(
|
||||
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
itemScope.launch {
|
||||
scope.launch {
|
||||
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
@@ -1233,17 +1241,16 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
|
||||
val range = chatViewItemsRange(currIndex, prevHidden)
|
||||
val reversed = reversedChatItems.value
|
||||
if (revealed.value && range != null) {
|
||||
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
|
||||
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1]
|
||||
reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
|
||||
val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1]
|
||||
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
} else {
|
||||
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
|
||||
if (i == reversed.lastIndex) {
|
||||
if (i == reversedChatItems.lastIndex) {
|
||||
DateSeparator(cItem.meta.itemTs)
|
||||
}
|
||||
}
|
||||
@@ -1251,7 +1258,7 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
itemScope.launch {
|
||||
scope.launch {
|
||||
delay(600)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
}
|
||||
@@ -1260,10 +1267,10 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState)
|
||||
FloatingButtons(chatModel.chatItems, unreadCount, remoteHostId, chatInfo, searchValue, markRead, setFloatingButton, listState)
|
||||
|
||||
FloatingDate(
|
||||
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
|
||||
Modifier.padding(top = 10.dp).align(Alignment.TopCenter),
|
||||
listState,
|
||||
)
|
||||
|
||||
@@ -1318,65 +1325,87 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.FloatingButtons(
|
||||
fun BoxWithConstraintsScope.FloatingButtons(
|
||||
chatItems: State<List<ChatItem>>,
|
||||
unreadCount: State<Int>,
|
||||
composeViewHeight: State<Dp>,
|
||||
remoteHostId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
|
||||
val bottomUnreadCount = remember {
|
||||
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
|
||||
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
|
||||
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
firstVisibleIndex = it
|
||||
firstItemIsVisible = firstVisibleIndex == 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
|
||||
// so separate them into two LaunchedEffects
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
lastIndexOfVisibleItems = it
|
||||
}
|
||||
}
|
||||
val bottomUnreadCount by remember {
|
||||
derivedStateOf {
|
||||
if (unreadCount.value == 0) return@derivedStateOf 0
|
||||
val items = chatItems.value
|
||||
val from = items.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
|
||||
val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
|
||||
if (items.size <= from || from < 0) return@derivedStateOf 0
|
||||
|
||||
items.subList(from, items.size).count { it.isRcvNew }
|
||||
}
|
||||
}
|
||||
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
|
||||
|
||||
val showBottomButtonWithCounter = remember { derivedStateOf { bottomUnreadCount.value > 0 && listState.firstVisibleItemIndex != 0 && searchValue.value.isEmpty() } }
|
||||
val showBottomButtonWithArrow = remember { derivedStateOf { !showBottomButtonWithCounter.value && listState.firstVisibleItemIndex != 0 } }
|
||||
BottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showBottomButtonWithCounter,
|
||||
showBottomButtonWithArrow,
|
||||
composeViewHeight,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
|
||||
}
|
||||
)
|
||||
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
|
||||
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
|
||||
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
|
||||
setFloatingButton(
|
||||
bottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showButtonWithCounter,
|
||||
showButtonWithArrow,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||
}
|
||||
))
|
||||
}
|
||||
// Don't show top FAB if is in search
|
||||
if (searchValue.value.isNotEmpty()) return
|
||||
val fabSize = 56.dp
|
||||
val topUnreadCount = remember { derivedStateOf { unreadCount.value - bottomUnreadCount.value } }
|
||||
val topUnreadCount by remember {
|
||||
derivedStateOf { unreadCount.value - bottomUnreadCount }
|
||||
}
|
||||
val showButtonWithCounter = topUnreadCount > 0
|
||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||
val showDropDown = remember { mutableStateOf(false) }
|
||||
|
||||
TopEndFloatingButton(
|
||||
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent()).align(Alignment.TopEnd),
|
||||
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp).align(Alignment.TopEnd),
|
||||
topUnreadCount,
|
||||
onClick = { scope.launch { listState.animateScrollBy(maxHeight.value.toFloat()) } },
|
||||
showButtonWithCounter,
|
||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||
onLongClick = { showDropDown.value = true }
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
|
||||
val density = LocalDensity.current
|
||||
val width = remember { mutableStateOf(250.dp) }
|
||||
DefaultDropdownMenu(
|
||||
showDropDown,
|
||||
modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } },
|
||||
offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent())
|
||||
) {
|
||||
Box {
|
||||
DefaultDropdownMenu(showDropDown, offset = DpOffset(this@FloatingButtons.maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
|
||||
ItemAction(
|
||||
generalGetString(MR.strings.mark_read),
|
||||
painterResource(MR.images.ic_check),
|
||||
@@ -1384,7 +1413,7 @@ fun BoxScope.FloatingButtons(
|
||||
val minUnreadItemId = chatModel.chats.value.firstOrNull { it.remoteHostId == remoteHostId && it.id == chatInfo.id }?.chatStats?.minUnreadItemId ?: return@ItemAction
|
||||
markRead(
|
||||
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||
bottomUnreadCount.value
|
||||
bottomUnreadCount
|
||||
)
|
||||
showDropDown.value = false
|
||||
})
|
||||
@@ -1446,11 +1475,12 @@ fun MemberImage(member: GroupMember) {
|
||||
@Composable
|
||||
private fun TopEndFloatingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
unreadCount: State<Int>,
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) = when {
|
||||
unreadCount.value > 0 -> {
|
||||
showButtonWithCounter -> {
|
||||
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
|
||||
FloatingActionButton(
|
||||
{}, // no action here
|
||||
@@ -1460,7 +1490,7 @@ private fun TopEndFloatingButton(
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount.value),
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
@@ -1470,16 +1500,6 @@ private fun TopEndFloatingButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun topPaddingToContent(): Dp {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
return if (oneHandUI.value) {
|
||||
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
} else {
|
||||
AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingDate(
|
||||
modifier: Modifier,
|
||||
@@ -1489,9 +1509,8 @@ private fun FloatingDate(
|
||||
var isNearBottom by remember { mutableStateOf(true) }
|
||||
val lastVisibleItemDate = remember {
|
||||
derivedStateOf {
|
||||
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0) {
|
||||
val lastFullyVisibleOffset = listState.layoutInfo.viewportEndOffset
|
||||
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - (listState.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset + item.size <= lastFullyVisibleOffset && item.size > 0 }?.index ?: 0)
|
||||
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0 && listState.firstVisibleItemIndex >= 0) {
|
||||
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
|
||||
val item = chatModel.chatItems.value.getOrNull(lastVisibleChatItemIndex)
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
item?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone)
|
||||
@@ -1678,44 +1697,48 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.BottomEndFloatingButton(
|
||||
unreadCount: State<Int>,
|
||||
showButtonWithCounter: State<Boolean>,
|
||||
showButtonWithArrow: State<Boolean>,
|
||||
composeViewHeight: State<Dp>,
|
||||
private fun bottomEndFloatingButton(
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
showButtonWithArrow: Boolean,
|
||||
onClickArrowDown: () -> Unit,
|
||||
onClickCounter: () -> Unit
|
||||
) = when {
|
||||
showButtonWithCounter.value -> {
|
||||
FloatingActionButton(
|
||||
onClick = onClickCounter,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
|
||||
backgroundColor = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount.value),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
): @Composable () -> Unit = when {
|
||||
showButtonWithCounter -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickCounter,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp),
|
||||
backgroundColor = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
showButtonWithArrow.value -> {
|
||||
FloatingActionButton(
|
||||
onClick = onClickArrowDown,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
|
||||
backgroundColor = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(MR.images.ic_keyboard_arrow_down),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
showButtonWithArrow -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickArrowDown,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp),
|
||||
backgroundColor = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(MR.images.ic_keyboard_arrow_down),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
else -> {
|
||||
{}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1842,25 +1865,6 @@ private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount:
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.chatViewBackgroundModifier(
|
||||
colors: Colors,
|
||||
wallpaper: AppWallpaper,
|
||||
backgroundGraphicsLayerSize: MutableState<IntSize>?,
|
||||
backgroundGraphicsLayer: GraphicsLayer?
|
||||
): Modifier {
|
||||
val wallpaperImage = wallpaper.type.image
|
||||
val wallpaperType = wallpaper.type
|
||||
val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background)
|
||||
val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
|
||||
|
||||
return this
|
||||
.then(if (wallpaperImage != null)
|
||||
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) }
|
||||
else
|
||||
Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } }
|
||||
)
|
||||
}
|
||||
|
||||
fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? =
|
||||
if (currIndex != null && prevHidden != null && prevHidden > currIndex) {
|
||||
currIndex..prevHidden
|
||||
|
||||
+2
-4
@@ -13,14 +13,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.filesToDelete
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
@@ -898,7 +896,7 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Divider()
|
||||
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
|
||||
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
|
||||
@@ -920,7 +918,7 @@ fun ComposeView(
|
||||
&& !nextSendGrpInv.value
|
||||
IconButton(
|
||||
attachmentClicked,
|
||||
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
|
||||
Modifier.padding(bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
|
||||
enabled = attachmentEnabled
|
||||
) {
|
||||
Icon(
|
||||
|
||||
+4
-1
@@ -81,7 +81,10 @@ private fun ContactPreferencesLayout(
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.contact_preferences))
|
||||
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
|
||||
+3
-4
@@ -1,11 +1,9 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import SectionBottomSpacer
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
@@ -14,7 +12,9 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
ColumnWithScrollBar {
|
||||
Column(
|
||||
Modifier.fillMaxSize()
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.scan_code))
|
||||
QRCodeScanner { text ->
|
||||
verifyCode(text) {
|
||||
@@ -28,6 +28,5 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: ()
|
||||
}
|
||||
}
|
||||
Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING))
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -12,7 +12,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.BackHandler
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -21,12 +20,11 @@ import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@Composable
|
||||
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
|
||||
fun SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
|
||||
val onBackClicked = { selectedChatItems.value = null }
|
||||
BackHandler(onBack = onBackClicked)
|
||||
val count = selectedChatItems.value?.size ?: 0
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
DefaultAppBar(
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
|
||||
title = {
|
||||
Text(
|
||||
@@ -41,9 +39,10 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
|
||||
)
|
||||
},
|
||||
onTitleClick = null,
|
||||
onTop = !oneHandUI.value,
|
||||
showSearch = false,
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -69,8 +68,6 @@ fun SelectedItemsBottomToolbar(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 2.dp)
|
||||
.height(AppBarHeight * fontSizeSqrtMultiplier)
|
||||
.pointerInput(Unit) {
|
||||
detectGesture {
|
||||
true
|
||||
@@ -106,7 +103,6 @@ fun SelectedItemsBottomToolbar(
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.align(Alignment.TopStart))
|
||||
}
|
||||
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
|
||||
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
|
||||
|
||||
+5
-8
@@ -7,6 +7,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
@@ -14,7 +15,6 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.*
|
||||
@@ -61,8 +61,7 @@ fun SendMsgView(
|
||||
) {
|
||||
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp)
|
||||
Box(Modifier.padding(padding)) {
|
||||
Box(Modifier.padding(vertical = if (appPlatform.isAndroid) 8.dp else 6.dp)) {
|
||||
val cs = composeState.value
|
||||
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(composeState.value.inProgress) {
|
||||
@@ -148,7 +147,7 @@ fun SendMsgView(
|
||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||
&& cs.contextItem is ComposeContextItem.NoContextItem
|
||||
) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton(userCanSend) {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
@@ -424,7 +423,6 @@ private fun SendMsgButton(
|
||||
onLongClick: (() -> Unit)? = null
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.combinedClickable(
|
||||
@@ -433,7 +431,7 @@ private fun SendMsgButton(
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
)
|
||||
.onRightClick { onLongClick?.invoke() },
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -456,7 +454,6 @@ private fun SendMsgButton(
|
||||
@Composable
|
||||
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.clickable(
|
||||
@@ -464,7 +461,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
||||
+5
-1
@@ -56,7 +56,11 @@ private fun VerifyCodeLayout(
|
||||
connectionVerified: Boolean,
|
||||
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.security_code), withPadding = false)
|
||||
val splitCode = splitToParts(connectionCode, 24)
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||
|
||||
+4
-1
@@ -130,7 +130,10 @@ fun AddGroupMembersLayout(
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.button_add_members))
|
||||
profileText()
|
||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||
|
||||
+2
-13
@@ -25,7 +25,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -284,14 +283,9 @@ fun ModalData.GroupChatInfoLayout(
|
||||
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) }
|
||||
}
|
||||
}
|
||||
Box {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
LazyColumnWithScrollBar(
|
||||
contentPadding = if (oneHandUI.value) {
|
||||
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||
} else {
|
||||
PaddingValues(top = topPaddingToContent())
|
||||
},
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
state = listState
|
||||
) {
|
||||
item {
|
||||
@@ -403,11 +397,6 @@ fun ModalData.GroupChatInfoLayout(
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value) {
|
||||
NavigationBarBackground(oneHandUI.value, oneHandUI.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -119,7 +119,9 @@ fun GroupLinkLayout(
|
||||
)
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier,
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.group_link))
|
||||
Text(
|
||||
stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||
|
||||
+4
-1
@@ -313,7 +313,10 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
|
||||
+3
-1
@@ -82,7 +82,9 @@ private fun GroupPreferencesLayout(
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.group_preferences))
|
||||
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
|
||||
+6
-2
@@ -82,9 +82,10 @@ fun GroupProfileLayout(
|
||||
}, close)
|
||||
}
|
||||
}
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.imePadding(),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
@@ -97,7 +98,9 @@ fun GroupProfileLayout(
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = closeWithAlert) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
@@ -174,6 +177,7 @@ fun GroupProfileLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean =
|
||||
|
||||
+3
-1
@@ -95,7 +95,9 @@ private fun GroupWelcomeLayout(
|
||||
linkMode: SimplexLinkMode,
|
||||
save: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val editMode = remember { mutableStateOf(true) }
|
||||
AppBarTitle(stringResource(MR.strings.group_welcome_title))
|
||||
val wt = rememberSaveable { welcomeText }
|
||||
|
||||
-67
@@ -10,7 +10,6 @@ import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -321,8 +320,6 @@ fun CIMarkdownText(
|
||||
|
||||
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||
const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
|
||||
const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose"
|
||||
const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose"
|
||||
/**
|
||||
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
|
||||
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
|
||||
@@ -401,70 +398,6 @@ fun DependentLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The purpose of this layout is to make measuring of bottom compose view and adapt top lazy column to its size in the same frame (not on the next frame as you would expect).
|
||||
// So, steps are:
|
||||
// - measuring the layout: measured height of compose view before this step is 0, it's added to content padding of lazy column (so it's == 0)
|
||||
// - measured the layout: measured height of compose view now is correct, but it's not yet applied to lazy column content padding (so it's == 0) and lazy column is placed higher than compose view in view with respect to compose view's height
|
||||
// - on next frame measured height is correct and content padding is the same, lazy column placed to occupy all parent view's size
|
||||
// - every added/removed line in compose view goes through the same process.
|
||||
@Composable
|
||||
fun AdaptingBottomPaddingLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
mainLayoutId: String,
|
||||
expectedHeight: MutableState<Dp>,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val expected = with(LocalDensity.current) { expectedHeight.value.roundToPx() }
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
) { measureable, constraints ->
|
||||
require(measureable.size <= 2) { "Should be exactly one or two elements in this layout, you have ${measureable.size}" }
|
||||
val mainPlaceable = measureable.firstOrNull { it.layoutId == mainLayoutId }!!.measure(constraints)
|
||||
val placeables: List<Placeable> = measureable.map {
|
||||
if (it.layoutId == mainLayoutId)
|
||||
mainPlaceable
|
||||
else
|
||||
it.measure(constraints.copy(maxHeight = if (expected != mainPlaceable.measuredHeight) constraints.maxHeight - mainPlaceable.measuredHeight + expected else constraints.maxHeight)) }
|
||||
expectedHeight.value = mainPlaceable.measuredHeight.toDp()
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
var y = 0
|
||||
placeables.forEach {
|
||||
if (it !== mainPlaceable) {
|
||||
it.place(0, y)
|
||||
y += it.measuredHeight
|
||||
} else {
|
||||
it.place(0, constraints.maxHeight - mainPlaceable.measuredHeight)
|
||||
y += it.measuredHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CenteredRowLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
) { measureable, constraints ->
|
||||
require(measureable.size == 3) { "Should be exactly three elements in this layout, you have ${measureable.size}" }
|
||||
val first = measureable[0].measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
val third = measureable[2].measure(constraints.copy(minWidth = first.measuredWidth, minHeight = 0))
|
||||
val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0)))
|
||||
// Limit width for every other element to width of important element and height for a sum of all elements.
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
|
||||
second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
|
||||
third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
|
||||
+3
-10
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.CryptoFile
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
@@ -57,17 +58,9 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
|
||||
DisposableEffectOnGone(
|
||||
always = {
|
||||
platform.androidSetStatusAndNavigationBarAppearance(false, false, blackNavBar = true)
|
||||
chatModel.fullscreenGalleryVisible.value = true
|
||||
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, Color.Black, false, false)
|
||||
},
|
||||
whenDispose = {
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
|
||||
chatModel.fullscreenGalleryVisible.value = false
|
||||
},
|
||||
whenGone = {
|
||||
playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) }
|
||||
}
|
||||
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
+163
-228
@@ -10,7 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
@@ -33,7 +34,6 @@ 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.newchat.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
@@ -41,6 +41,7 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URI
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
|
||||
@@ -54,7 +55,7 @@ private fun showNewChatSheet(oneHandUI: State<Boolean>) {
|
||||
chatModel.newChatSheetVisible.value = false
|
||||
close()
|
||||
}
|
||||
ModalView(close, showAppBar = !oneHandUI.value) {
|
||||
ModalView(close, closeOnTop = !oneHandUI.value) {
|
||||
if (appPlatform.isAndroid) {
|
||||
BackHandler {
|
||||
close()
|
||||
@@ -121,7 +122,11 @@ fun ToggleChatListCard() {
|
||||
|
||||
SharedPreferenceToggle(
|
||||
appPrefs.oneHandUI,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
onChange = {
|
||||
val c = CurrentColors.value.colors
|
||||
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,36 +154,74 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
}
|
||||
}
|
||||
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (oneHandUI.value) {
|
||||
ChatListWithLoadingScreen(searchText, listState)
|
||||
Column(Modifier.align(Alignment.BottomCenter)) {
|
||||
ChatListToolbar(
|
||||
userPickerState,
|
||||
listState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!oneHandUI.value) {
|
||||
Column {
|
||||
ChatListToolbar(
|
||||
userPickerState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatListWithLoadingScreen(searchText, listState)
|
||||
Column {
|
||||
ChatListToolbar(
|
||||
userPickerState,
|
||||
listState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (oneHandUI.value) {
|
||||
Column {
|
||||
Divider()
|
||||
ChatListToolbar(
|
||||
userPickerState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
|
||||
NewChatSheetFloatingButton(oneHandUI, stopped)
|
||||
},
|
||||
contentColor = LocalContentColor.current,
|
||||
floatingActionButton = {
|
||||
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!stopped) {
|
||||
showNewChatSheet(oneHandUI)
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
|
||||
.size(AppBarHeight * fontSizeSqrtMultiplier),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
),
|
||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
ChatList(chatModel, searchText = searchText)
|
||||
}
|
||||
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
|
||||
Text(stringResource(
|
||||
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchText.value.text.isEmpty()) {
|
||||
if (appPlatform.isDesktop && !oneHandUI.value) {
|
||||
if (appPlatform.isDesktop) {
|
||||
val call = remember { chatModel.activeCall }.value
|
||||
if (call != null) {
|
||||
ActiveCallInteractiveArea(call)
|
||||
@@ -196,46 +239,6 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
ChatList(searchText = searchText, listState)
|
||||
}
|
||||
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats
|
||||
), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.NewChatSheetFloatingButton(oneHandUI: State<Boolean>, stopped: Boolean) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!stopped) {
|
||||
showNewChatSheet(oneHandUI)
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(AppBarHeight * fontSizeSqrtMultiplier),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
),
|
||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectButton(text: String, onClick: () -> Unit) {
|
||||
Button(
|
||||
@@ -253,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, listState: LazyListState, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
|
||||
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
|
||||
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val updatingProgress = remember { chatModel.updatingProgress }.value
|
||||
@@ -262,18 +265,6 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
|
||||
if (oneHandUI.value) {
|
||||
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
|
||||
|
||||
if (appPlatform.isDesktop && oneHandUI.value) {
|
||||
val call = remember { chatModel.activeCall }
|
||||
if (call.value != null) {
|
||||
barButtons.add {
|
||||
val c = call.value
|
||||
if (c != null) {
|
||||
ActiveCallInteractiveArea(c)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stopped) {
|
||||
barButtons.add {
|
||||
IconButton(
|
||||
@@ -332,9 +323,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val canScrollToZero = remember { derivedStateOf { listState.firstVisibleItemIndex != 0 || listState.firstVisibleItemScrollOffset != 0 } }
|
||||
DefaultAppBar(
|
||||
DefaultTopAppBar(
|
||||
navigationButton = {
|
||||
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
|
||||
NavigationButtonMenu {
|
||||
@@ -362,14 +351,15 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
|
||||
SubscriptionStatusIndicator(
|
||||
click = {
|
||||
ModalManager.start.closeModals()
|
||||
val summary = serversSummary.value
|
||||
ModalManager.start.showModalCloseable(
|
||||
endButtons = {
|
||||
val summary = serversSummary.value
|
||||
if (summary != null) {
|
||||
ShareButton {
|
||||
val json = Json {
|
||||
prettyPrint = true
|
||||
}
|
||||
|
||||
val text = json.encodeToString(PresentedServersSummary.serializer(), summary)
|
||||
clipboard.shareText(text)
|
||||
}
|
||||
@@ -380,10 +370,10 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
|
||||
)
|
||||
}
|
||||
},
|
||||
onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null,
|
||||
onTop = !oneHandUI.value,
|
||||
onTitleClick = null,
|
||||
showSearch = false,
|
||||
onSearchValueChanged = {},
|
||||
buttons = { barButtons.forEach { it() } }
|
||||
buttons = barButtons
|
||||
)
|
||||
}
|
||||
|
||||
@@ -501,78 +491,74 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
|
||||
|
||||
@Composable
|
||||
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
|
||||
Box {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Icon(
|
||||
painterResource(MR.images.ic_search),
|
||||
contentDescription = null,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
SearchTextField(
|
||||
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
|
||||
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
|
||||
alwaysVisible = true,
|
||||
searchText = searchText,
|
||||
enabled = !remember { searchShowingSimplexLink }.value,
|
||||
trailingContent = null,
|
||||
) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Icon(
|
||||
painterResource(MR.images.ic_search),
|
||||
contentDescription = null,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
SearchTextField(
|
||||
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
|
||||
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
|
||||
alwaysVisible = true,
|
||||
searchText = searchText,
|
||||
enabled = !remember { searchShowingSimplexLink }.value,
|
||||
trailingContent = null,
|
||||
) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
}
|
||||
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
|
||||
if (hasText.value) {
|
||||
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
KeyChangeEffect(chatModel.currentRemoteHost.value) {
|
||||
hideSearchOnBack()
|
||||
}
|
||||
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
|
||||
if (hasText.value) {
|
||||
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
KeyChangeEffect(chatModel.currentRemoteHost.value) {
|
||||
hideSearchOnBack()
|
||||
}
|
||||
} else {
|
||||
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
|
||||
if (chatModel.chats.value.isNotEmpty()) {
|
||||
ToggleFilterEnabledButton()
|
||||
}
|
||||
Spacer(Modifier.width(padding))
|
||||
} else {
|
||||
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
|
||||
if (chatModel.chats.value.isNotEmpty()) {
|
||||
ToggleFilterEnabledButton()
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardState = getKeyboardState()
|
||||
LaunchedEffect(keyboardState.value) {
|
||||
if (keyboardState.value == KeyboardState.Closed && focused) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
val view = LocalMultiplatformView()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { searchText.value.text }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
val link = strHasSingleSimplexLink(it.trim())
|
||||
if (link != null) {
|
||||
// if SimpleX link is pasted, show connection dialogue
|
||||
hideKeyboard(view)
|
||||
if (link.format is Format.SimplexLink) {
|
||||
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
|
||||
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
|
||||
}
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
|
||||
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
// if some other text is pasted, enter search mode
|
||||
focusRequester.requestFocus()
|
||||
} else if (listState.layoutInfo.totalItemsCount > 0) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
searchShowingSimplexLink.value = false
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(padding))
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardState = getKeyboardState()
|
||||
LaunchedEffect(keyboardState.value) {
|
||||
if (keyboardState.value == KeyboardState.Closed && focused) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Divider(Modifier.align(if (oneHandUI.value) Alignment.TopStart else Alignment.BottomStart))
|
||||
val view = LocalMultiplatformView()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { searchText.value.text }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
val link = strHasSingleSimplexLink(it.trim())
|
||||
if (link != null) {
|
||||
// if SimpleX link is pasted, show connection dialogue
|
||||
hideKeyboard(view)
|
||||
if (link.format is Format.SimplexLink) {
|
||||
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
|
||||
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
|
||||
}
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
|
||||
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
// if some other text is pasted, enter search mode
|
||||
focusRequester.requestFocus()
|
||||
} else if (listState.layoutInfo.totalItemsCount > 0) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
searchShowingSimplexLink.value = false
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,37 +590,8 @@ enum class ScrollDirection {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.StatusBarBackground() {
|
||||
if (appPlatform.isAndroid) {
|
||||
val finalColor = MaterialTheme.colors.background.copy(0.88f)
|
||||
Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.NavigationBarBackground(appBarOnBottom: Boolean = false, mixedColor: Boolean, noAlpha: Boolean = false) {
|
||||
if (appPlatform.isAndroid) {
|
||||
val barPadding = WindowInsets.navigationBars.asPaddingValues()
|
||||
val paddingBottom = barPadding.calculateBottomPadding()
|
||||
val color = if (mixedColor) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) else MaterialTheme.colors.background
|
||||
val finalColor = color.copy(if (noAlpha) 1f else if (appBarOnBottom) remember { appPrefs.inAppBarsAlpha.state }.value else 0.6f)
|
||||
Box(Modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = MaterialTheme.colors.background) {
|
||||
val keyboardState = getKeyboardState()
|
||||
if (appPlatform.isAndroid && keyboardState.value == KeyboardState.Closed) {
|
||||
val barPadding = WindowInsets.navigationBars.asPaddingValues()
|
||||
val paddingBottom = barPadding.calculateBottomPadding()
|
||||
val finalColor = color.copy(0.6f)
|
||||
Box(modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
|
||||
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
|
||||
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
|
||||
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
|
||||
var previousIndex by remember { mutableStateOf(0) }
|
||||
var previousScrollOffset by remember { mutableStateOf(0) }
|
||||
@@ -671,45 +628,40 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
|
||||
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
|
||||
val topPaddingToContent = topPaddingToContent()
|
||||
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
|
||||
LazyColumnWithScrollBar(
|
||||
if (!oneHandUI.value) Modifier.imePadding() else Modifier,
|
||||
Modifier.fillMaxSize(),
|
||||
listState,
|
||||
reverseLayout = oneHandUI.value
|
||||
) {
|
||||
item { Spacer(Modifier.height(blankSpaceSize)) }
|
||||
stickyHeader {
|
||||
Column(
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.offset {
|
||||
val offsetMultiplier = if (oneHandUI.value) 1 else -1
|
||||
val y = if (searchText.value.text.isNotEmpty() || (appPlatform.isAndroid && keyboardState == KeyboardState.Opened) || scrollDirection == ScrollDirection.Up) {
|
||||
if (listState.firstVisibleItemIndex == 0) -offsetMultiplier * listState.firstVisibleItemScrollOffset
|
||||
else -offsetMultiplier * blankSpaceSize.roundToPx()
|
||||
val y = if (searchText.value.text.isEmpty()) {
|
||||
val offsetMultiplier = if (oneHandUI.value) 1 else -1
|
||||
if (
|
||||
(oneHandUI.value && scrollDirection == ScrollDirection.Up) ||
|
||||
(appPlatform.isAndroid && keyboardState == KeyboardState.Opened)
|
||||
) {
|
||||
0
|
||||
} else if (listState.firstVisibleItemIndex == 0) offsetMultiplier * listState.firstVisibleItemScrollOffset else offsetMultiplier * 1000
|
||||
} else {
|
||||
when (listState.firstVisibleItemIndex) {
|
||||
0 -> 0
|
||||
1 -> offsetMultiplier * listState.firstVisibleItemScrollOffset
|
||||
else -> offsetMultiplier * 1000
|
||||
}
|
||||
0
|
||||
}
|
||||
IntOffset(0, y)
|
||||
}
|
||||
.background(MaterialTheme.colors.background)
|
||||
.background(MaterialTheme.colors.background),
|
||||
) {
|
||||
if (oneHandUI.value) {
|
||||
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
}
|
||||
} else {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
Divider()
|
||||
}
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
if (!oneHandUI.value) {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!oneHandUICardShown.value && chats.size > 1) {
|
||||
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() > 1) {
|
||||
item {
|
||||
ToggleChatListCard()
|
||||
}
|
||||
@@ -720,30 +672,17 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
} }
|
||||
ChatListNavLinkView(chat, nextChatSelected)
|
||||
}
|
||||
if (!oneHandUICardShown.value && chats.size <= 1) {
|
||||
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() <= 1) {
|
||||
item {
|
||||
ToggleChatListCard()
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
item { Spacer(if (oneHandUI.value) Modifier.windowInsetsTopHeight(WindowInsets.statusBars) else Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) }
|
||||
}
|
||||
}
|
||||
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
|
||||
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
if (oneHandUI.value) {
|
||||
StatusBarBackground()
|
||||
} else {
|
||||
NavigationBarBackground(oneHandUI.value, true)
|
||||
}
|
||||
if (!oneHandUICardShown.value) {
|
||||
LaunchedEffect(chats.size) {
|
||||
if (chats.size >= 3) appPrefs.oneHandUICardShown.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filteredChats(
|
||||
@@ -788,7 +727,3 @@ private fun filtered(chat: Chat): Boolean =
|
||||
(chat.chatInfo.chatSettings?.favorite ?: false) ||
|
||||
chat.chatStats.unreadChat ||
|
||||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
|
||||
|
||||
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
|
||||
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
|
||||
}
|
||||
|
||||
+15
-5
@@ -620,7 +620,9 @@ fun ModalData.SMPServerSummaryView(
|
||||
ModalView(
|
||||
close = close
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
val bottomPadding = DEFAULT_PADDING
|
||||
AppBarTitle(
|
||||
stringResource(MR.strings.smp_server),
|
||||
@@ -643,7 +645,9 @@ fun ModalData.DetailedXFTPStatsView(
|
||||
ModalView(
|
||||
close = close
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val bottomPadding = DEFAULT_PADDING
|
||||
AppBarTitle(
|
||||
@@ -667,7 +671,9 @@ fun ModalData.DetailedSMPStatsView(
|
||||
ModalView(
|
||||
close = close
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val bottomPadding = DEFAULT_PADDING
|
||||
AppBarTitle(
|
||||
@@ -691,7 +697,9 @@ fun ModalData.XFTPServerSummaryView(
|
||||
ModalView(
|
||||
close = close
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val bottomPadding = DEFAULT_PADDING
|
||||
AppBarTitle(
|
||||
@@ -707,7 +715,9 @@ fun ModalData.XFTPServerSummaryView(
|
||||
|
||||
@Composable
|
||||
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
var showUserSelection by remember { mutableStateOf(false) }
|
||||
val selectedUserCategory =
|
||||
remember { stateGetOrPut("selectedUserCategory") { PresentedUserCategory.ALL_USERS } }
|
||||
|
||||
+67
-55
@@ -11,13 +11,10 @@ import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.themedBackground
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
import chat.simplex.common.views.newchat.ActiveProfilePicker
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@@ -25,7 +22,26 @@ import chat.simplex.res.MR
|
||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Box(Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
|
||||
|
||||
Scaffold(
|
||||
contentColor = LocalContentColor.current,
|
||||
topBar = {
|
||||
if (!oneHandUI.value) {
|
||||
Column {
|
||||
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (oneHandUI.value) {
|
||||
Column {
|
||||
Divider()
|
||||
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val sharedContent = chatModel.sharedContent.value
|
||||
var isMediaOrFileAttachment = false
|
||||
var isVoice = false
|
||||
@@ -53,24 +69,22 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
if (chatModel.chats.value.isNotEmpty()) {
|
||||
ShareList(
|
||||
chatModel,
|
||||
search = searchInList,
|
||||
isMediaOrFileAttachment = isMediaOrFileAttachment,
|
||||
isVoice = isVoice,
|
||||
hasSimplexLink = hasSimplexLink,
|
||||
)
|
||||
} else {
|
||||
EmptyList()
|
||||
}
|
||||
if (oneHandUI.value) {
|
||||
StatusBarBackground()
|
||||
} else {
|
||||
NavigationBarBackground(oneHandUI.value, true)
|
||||
}
|
||||
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
|
||||
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (chatModel.chats.value.isNotEmpty()) {
|
||||
ShareList(
|
||||
chatModel,
|
||||
search = searchInList,
|
||||
isMediaOrFileAttachment = isMediaOrFileAttachment,
|
||||
isVoice = isVoice,
|
||||
hasSimplexLink = hasSimplexLink,
|
||||
)
|
||||
} else {
|
||||
EmptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +108,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
if (showSearch) {
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val navButton: @Composable RowScope.() -> Unit = {
|
||||
when {
|
||||
@@ -103,13 +118,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
showSearch = true,
|
||||
searchAlwaysVisible = true,
|
||||
onSearchValueChanged = { search.value = it },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = {
|
||||
ActiveProfilePicker(
|
||||
search = search,
|
||||
@@ -133,8 +148,31 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
})
|
||||
}
|
||||
}
|
||||
if (chatModel.chats.value.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stopped) {
|
||||
barButtons.add {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_report_filled),
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DefaultAppBar(
|
||||
DefaultTopAppBar(
|
||||
navigationButton = navButton,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -153,29 +191,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = showSearch,
|
||||
onTop = !remember { appPrefs.oneHandUI.state }.value,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = {
|
||||
if (chatModel.chats.value.size >= 8) {
|
||||
IconButton({ showSearch = true }) {
|
||||
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
if (stopped) {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_report_filled),
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
buttons = barButtons
|
||||
)
|
||||
}
|
||||
|
||||
@@ -194,13 +211,8 @@ private fun ShareList(
|
||||
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
|
||||
}
|
||||
}
|
||||
val topPaddingToContent = topPaddingToContent()
|
||||
LazyColumnWithScrollBar(
|
||||
modifier = Modifier.then(if (oneHandUI.value) Modifier.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Vertical)) else Modifier).imePadding(),
|
||||
contentPadding = PaddingValues(
|
||||
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
|
||||
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
reverseLayout = oneHandUI.value
|
||||
) {
|
||||
items(chats) { chat ->
|
||||
|
||||
+36
-65
@@ -23,7 +23,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.ui.theme.*
|
||||
@@ -138,16 +137,12 @@ fun UserPicker(
|
||||
}
|
||||
}
|
||||
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val iconColor = MaterialTheme.colors.secondaryVariant
|
||||
val background = if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface
|
||||
PlatformUserPicker(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth()
|
||||
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true, ambientColor = background) else Modifier)
|
||||
.padding(top = if (appPlatform.isDesktop && oneHandUI.value) 7.dp else 0.dp)
|
||||
.background(background)
|
||||
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
|
||||
.background(if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface)
|
||||
.padding(bottom = USER_PICKER_SECTION_SPACING - DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
|
||||
pickerState = userPickerState
|
||||
) {
|
||||
@@ -203,13 +198,12 @@ fun UserPicker(
|
||||
UserPickerUsersSection(
|
||||
users = users,
|
||||
onUserClicked = onUserClicked,
|
||||
iconColor = iconColor,
|
||||
stopped = stopped
|
||||
)
|
||||
}
|
||||
} else if (currentUser != null) {
|
||||
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
|
||||
ProfilePreview(currentUser.profile, stopped = stopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +234,6 @@ fun UserPicker(
|
||||
Column(modifier = Modifier.padding(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)) {
|
||||
UserPickerUsersSection(
|
||||
users = inactiveUsers,
|
||||
iconColor = iconColor,
|
||||
onUserClicked = onUserClicked,
|
||||
stopped = stopped
|
||||
)
|
||||
@@ -268,32 +261,20 @@ fun UserPicker(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
{
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
ModalView(
|
||||
{ close() },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { UserProfilesView(chatModel, search, profileHidden) })
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled = stopped
|
||||
@@ -422,35 +403,26 @@ 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 {
|
||||
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))
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +519,6 @@ private fun DevicePickerRow(
|
||||
@Composable
|
||||
expect fun UserPickerUsersSection(
|
||||
users: List<UserInfo>,
|
||||
iconColor: Color,
|
||||
stopped: Boolean,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
)
|
||||
|
||||
+3
-1
@@ -46,7 +46,9 @@ fun ChatArchiveLayout(
|
||||
saveArchive: () -> Unit,
|
||||
deleteArchiveAlert: () -> Unit
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(title)
|
||||
SectionView(stringResource(MR.strings.chat_archive_section)) {
|
||||
SettingsActionItem(
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ fun DatabaseEncryptionLayout(
|
||||
Layout()
|
||||
}
|
||||
} else {
|
||||
ColumnWithScrollBar(maxIntrinsicSize = true) {
|
||||
ColumnWithScrollBar(Modifier.fillMaxWidth(), maxIntrinsicSize = true) {
|
||||
Layout()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -77,7 +77,10 @@ fun DatabaseErrorView(
|
||||
Text(String.format(generalGetString(MR.strings.database_migrations), ms.joinToString(", ")))
|
||||
}
|
||||
|
||||
ColumnWithScrollBarNoAppBar(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
|
||||
+3
-1
@@ -156,7 +156,9 @@ fun DatabaseLayout(
|
||||
val stopped = !runChat
|
||||
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.your_chat_database))
|
||||
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.res.MR
|
||||
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) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = handler?.connection
|
||||
LaunchedEffect(title) {
|
||||
handler?.title?.value = title
|
||||
}
|
||||
val theme = CurrentColors.collectAsState()
|
||||
val titleColor = MaterialTheme.appColors.title
|
||||
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
|
||||
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||
else // color is not updated when changing themes if I pass null here
|
||||
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
Modifier
|
||||
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
|
||||
.graphicsLayer {
|
||||
alpha = bottomTitleAlpha(connection)
|
||||
},
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1.copy(brush = brush),
|
||||
color = MaterialTheme.colors.primaryVariant,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
if (hostDevice != null) {
|
||||
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
|
||||
alpha = bottomTitleAlpha(connection)
|
||||
}) {
|
||||
HostDeviceTitle(hostDevice)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
|
||||
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
|
||||
|
||||
@Composable
|
||||
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
|
||||
DevicePill(
|
||||
active = true,
|
||||
onClick = {},
|
||||
actionButtonVisible = false,
|
||||
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
|
||||
text = hostDevice.second
|
||||
)
|
||||
}
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
|
||||
fun Modifier.blurredBackgroundModifier(
|
||||
keyboardInset: WindowInsets,
|
||||
handler: AppBarHandler?,
|
||||
blurRadius: State<Int>,
|
||||
prefAlpha: State<Float>,
|
||||
keyboardCoversBar: Boolean,
|
||||
onTop: Boolean,
|
||||
density: Density
|
||||
): Modifier {
|
||||
val graphicsLayer = handler?.graphicsLayer
|
||||
val backgroundGraphicsLayer = handler?.backgroundGraphicsLayer
|
||||
val backgroundGraphicsLayerSize = handler?.backgroundGraphicsLayerSize
|
||||
if (handler == null || graphicsLayer == null || backgroundGraphicsLayer == null || blurRadius.value == 0 || prefAlpha.value == 1f || backgroundGraphicsLayerSize === null)
|
||||
return this
|
||||
|
||||
return if (appPlatform.isAndroid) {
|
||||
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
|
||||
} else {
|
||||
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
|
||||
}
|
||||
}
|
||||
|
||||
// this is more performant version than for Android but can't be used on desktop because on first frame it shows transparent view
|
||||
// which is very noticeable on desktop and unnoticeable on Android
|
||||
private fun Modifier.androidBlurredModifier(
|
||||
keyboardInset: WindowInsets,
|
||||
blurRadius: Int,
|
||||
keyboardCoversBar: Boolean,
|
||||
onTop: Boolean,
|
||||
graphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayerSize: State<IntSize>,
|
||||
density: Density
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
renderEffect = if (blurRadius > 0) BlurEffect(blurRadius.dp.toPx(), blurRadius.dp.toPx()) else null
|
||||
clip = blurRadius > 0
|
||||
}
|
||||
.graphicsLayer {
|
||||
if (!onTop) {
|
||||
val bgSize = when {
|
||||
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
|
||||
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
|
||||
else -> backgroundGraphicsLayerSize.value.height
|
||||
}
|
||||
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
|
||||
translationY = -bgSize + size.height + keyboardHeightCovered
|
||||
}
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(Color.Black)
|
||||
if (onTop) {
|
||||
clipRect {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
} else {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
if (!onTop) {
|
||||
val bgSize = when {
|
||||
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
|
||||
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
|
||||
else -> backgroundGraphicsLayerSize.value.height
|
||||
}
|
||||
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
|
||||
translationY -= -bgSize + size.height + keyboardHeightCovered
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.desktopBlurredModifier(
|
||||
keyboardInset: WindowInsets,
|
||||
blurRadius: State<Int>,
|
||||
keyboardCoversBar: Boolean,
|
||||
onTop: Boolean,
|
||||
graphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayerSize: State<IntSize>,
|
||||
density: Density
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
|
||||
clip = blurRadius.value > 0
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(Color.Black)
|
||||
if (onTop) {
|
||||
clipRect {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
} else {
|
||||
val bgSize = when {
|
||||
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
|
||||
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
|
||||
else -> backgroundGraphicsLayerSize.value.height
|
||||
}
|
||||
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
|
||||
translate(top = -bgSize + size.height + keyboardHeightCovered) {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-52
@@ -1,13 +1,11 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.draw.CacheDrawScope
|
||||
import androidx.compose.ui.draw.DrawResult
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
@@ -383,14 +381,7 @@ private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, siz
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun CacheDrawScope.chatViewBackground(
|
||||
image: ImageBitmap,
|
||||
imageType: WallpaperType,
|
||||
background: Color,
|
||||
tint: Color,
|
||||
graphicsLayerSize: MutableState<IntSize>? = null,
|
||||
backgroundGraphicsLayer: GraphicsLayer? = null
|
||||
): DrawResult {
|
||||
fun CacheDrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperType, background: Color, tint: Color): DrawResult {
|
||||
val imageScale = if (imageType is WallpaperType.Preset) {
|
||||
(imageType.scale ?: 1f) * imageType.predefinedImageScale
|
||||
} else if (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT) {
|
||||
@@ -405,55 +396,53 @@ fun CacheDrawScope.chatViewBackground(
|
||||
}
|
||||
|
||||
return onDrawBehind {
|
||||
copyBackgroundToAppBar(graphicsLayerSize, backgroundGraphicsLayer) {
|
||||
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
|
||||
drawRect(background)
|
||||
when (imageType) {
|
||||
is WallpaperType.Preset -> drawImage(image)
|
||||
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
|
||||
WallpaperScaleType.REPEAT -> drawImage(image)
|
||||
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
|
||||
clipRect {
|
||||
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
|
||||
val scaledWidth = (image.width * scale.scaleX).roundToInt()
|
||||
val scaledHeight = (image.height * scale.scaleY).roundToInt()
|
||||
// Large image will cause freeze
|
||||
if (image.width > 4320 || image.height > 4320) return@clipRect
|
||||
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
|
||||
drawRect(background)
|
||||
when (imageType) {
|
||||
is WallpaperType.Preset -> drawImage(image)
|
||||
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
|
||||
WallpaperScaleType.REPEAT -> drawImage(image)
|
||||
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
|
||||
clipRect {
|
||||
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
|
||||
val scaledWidth = (image.width * scale.scaleX).roundToInt()
|
||||
val scaledHeight = (image.height * scale.scaleY).roundToInt()
|
||||
// Large image will cause freeze
|
||||
if (image.width > 4320 || image.height > 4320) return@clipRect
|
||||
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
if (scaleType == WallpaperScaleType.FIT) {
|
||||
if (scaledWidth < size.width) {
|
||||
// has black lines at left and right sides
|
||||
var x = (size.width - scaledWidth) / 2
|
||||
while (x > 0) {
|
||||
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
x -= scaledWidth
|
||||
}
|
||||
x = size.width - (size.width - scaledWidth) / 2
|
||||
while (x < size.width) {
|
||||
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
x += scaledWidth
|
||||
}
|
||||
} else {
|
||||
// has black lines at top and bottom sides
|
||||
var y = (size.height - scaledHeight) / 2
|
||||
while (y > 0) {
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
y -= scaledHeight
|
||||
}
|
||||
y = size.height - (size.height - scaledHeight) / 2
|
||||
while (y < size.height) {
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
y += scaledHeight
|
||||
}
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
if (scaleType == WallpaperScaleType.FIT) {
|
||||
if (scaledWidth < size.width) {
|
||||
// has black lines at left and right sides
|
||||
var x = (size.width - scaledWidth) / 2
|
||||
while (x > 0) {
|
||||
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
x -= scaledWidth
|
||||
}
|
||||
x = size.width - (size.width - scaledWidth) / 2
|
||||
while (x < size.width) {
|
||||
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
x += scaledWidth
|
||||
}
|
||||
} else {
|
||||
// has black lines at top and bottom sides
|
||||
var y = (size.height - scaledHeight) / 2
|
||||
while (y > 0) {
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
y -= scaledHeight
|
||||
}
|
||||
y = size.height - (size.height - scaledHeight) / 2
|
||||
while (y < size.height) {
|
||||
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
|
||||
y += scaledHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
drawRect(tint)
|
||||
}
|
||||
drawRect(tint)
|
||||
}
|
||||
is WallpaperType.Empty -> {}
|
||||
}
|
||||
is WallpaperType.Empty -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -19,8 +19,6 @@ fun ChooseAttachmentView(attachmentOption: MutableState<AttachmentOption?>, hide
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hide()
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.DevicePill
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, barPaddingValues: PaddingValues = PaddingValues(horizontal = AppBarHorizontalPadding), endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||
var rowModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(AppBarHeight * fontSizeSqrtMultiplier)
|
||||
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
|
||||
if (!closeBarTitle.isNullOrEmpty()) {
|
||||
rowModifier = rowModifier.background(themeBackgroundMix)
|
||||
}
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = LocalAppBarHandler.current?.connection
|
||||
val title = remember(handler?.title?.value) { handler?.title ?: mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
verticalArrangement = arrangement,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
|
||||
.drawWithCache {
|
||||
val backgroundColor = if (appPlatform.isDesktop && connection != null) themeBackgroundMix.copy(alpha = topTitleAlpha(connection)) else Color.Transparent
|
||||
onDrawBehind {
|
||||
if (appPlatform.isDesktop) {
|
||||
drawRect(backgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(barPaddingValues),
|
||||
content = {
|
||||
Row(
|
||||
rowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (showClose) {
|
||||
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
if (!closeBarTitle.isNullOrEmpty()) {
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
closeBarTitle,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
} else if (title.value.isNotEmpty() && connection != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = if (showClose) 0.dp else DEFAULT_PADDING_HALF)
|
||||
.weight(1f) // hides the title if something wants full width (eg, search field in chat profiles screen)
|
||||
.graphicsLayer {
|
||||
alpha = topTitleAlpha((connection))
|
||||
}
|
||||
.padding(start = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
title.value,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
Row {
|
||||
endButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (closeBarTitle.isNullOrEmpty() && title.value.isNotEmpty() && connection != null) {
|
||||
Divider(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = topTitleAlpha(connection)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = handler?.connection
|
||||
LaunchedEffect(title) {
|
||||
handler?.title?.value = title
|
||||
}
|
||||
val theme = CurrentColors.collectAsState()
|
||||
val titleColor = MaterialTheme.appColors.title
|
||||
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
|
||||
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||
else // color is not updated when changing themes if I pass null here
|
||||
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
Modifier
|
||||
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
|
||||
.graphicsLayer {
|
||||
alpha = bottomTitleAlpha(connection)
|
||||
},
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1.copy(brush = brush),
|
||||
color = MaterialTheme.colors.primaryVariant,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
if (hostDevice != null) {
|
||||
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
|
||||
alpha = bottomTitleAlpha(connection)
|
||||
}) {
|
||||
HostDeviceTitle(hostDevice)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
private fun topTitleAlpha(connection: CollapsingAppBarNestedScrollConnection) =
|
||||
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
|
||||
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, 1f)
|
||||
|
||||
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
|
||||
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
|
||||
|
||||
@Composable
|
||||
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
|
||||
DevicePill(
|
||||
active = true,
|
||||
onClick = {},
|
||||
actionButtonVisible = false,
|
||||
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
|
||||
text = hostDevice.second
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
CloseSheetBar(close = {})
|
||||
}
|
||||
}
|
||||
+1
-55
@@ -3,67 +3,15 @@ package chat.simplex.common.views.helpers
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
val LocalAppBarHandler: ProvidableCompositionLocal<AppBarHandler?> = staticCompositionLocalOf { null }
|
||||
|
||||
@Composable
|
||||
fun rememberAppBarHandler(key1: Any? = null, key2: Any? = null, keyboardCoversBar: Boolean = true): AppBarHandler {
|
||||
val graphicsLayer = rememberGraphicsLayer()
|
||||
val backgroundGraphicsLayer = rememberGraphicsLayer()
|
||||
return remember(key1, key2) { AppBarHandler(graphicsLayer, backgroundGraphicsLayer, keyboardCoversBar) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun adjustAppBarHandler(handler: AppBarHandler): AppBarHandler {
|
||||
val graphicsLayer = rememberGraphicsLayer()
|
||||
val backgroundGraphicsLayer = rememberGraphicsLayer()
|
||||
if (handler.graphicsLayer == null || handler.graphicsLayer?.isReleased == true || handler.backgroundGraphicsLayer?.isReleased == true) {
|
||||
handler.graphicsLayer = graphicsLayer
|
||||
handler.backgroundGraphicsLayer = backgroundGraphicsLayer
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
fun Modifier.copyViewToAppBar(blurRadius: Int, graphicsLayer: GraphicsLayer?): Modifier {
|
||||
return if (blurRadius > 0 && graphicsLayer != null) {
|
||||
this.drawWithContent {
|
||||
graphicsLayer.record {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
} else this
|
||||
}
|
||||
|
||||
fun DrawScope.copyBackgroundToAppBar(graphicsLayerSize: MutableState<IntSize>?, backgroundGraphicsLayer: GraphicsLayer?, scope: DrawScope.() -> Unit) {
|
||||
val blurRadius = appPrefs.appearanceBarsBlurRadius.get()
|
||||
if (blurRadius > 0 && graphicsLayerSize != null && backgroundGraphicsLayer != null) {
|
||||
graphicsLayerSize.value = backgroundGraphicsLayer.size
|
||||
backgroundGraphicsLayer.record {
|
||||
scope()
|
||||
}
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
scope()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AppBarHandler(
|
||||
var graphicsLayer: GraphicsLayer?,
|
||||
var backgroundGraphicsLayer: GraphicsLayer?,
|
||||
val keyboardCoversBar: Boolean = true,
|
||||
listState: LazyListState = LazyListState(0, 0),
|
||||
scrollState: ScrollState = ScrollState(initial = 0)
|
||||
) {
|
||||
@@ -76,8 +24,6 @@ class AppBarHandler(
|
||||
|
||||
val connection = CollapsingAppBarNestedScrollConnection()
|
||||
|
||||
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
|
||||
|
||||
companion object {
|
||||
var appBarMaxHeightPx: Int = 0
|
||||
}
|
||||
|
||||
+3
@@ -3,6 +3,7 @@ package chat.simplex.common.views.helpers
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
@@ -21,11 +22,13 @@ import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.views.database.PassphraseStrength
|
||||
import chat.simplex.common.views.database.validKey
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DefaultBasicTextField(
|
||||
modifier: Modifier,
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
fun DefaultDropdownMenu(
|
||||
showMenu: MutableState<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
dropdownMenuItems: (@Composable () -> Unit)?
|
||||
) {
|
||||
@@ -24,7 +23,7 @@ fun DefaultDropdownMenu(
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.widthIn(min = 250.dp)
|
||||
.background(MaterialTheme.colors.surface)
|
||||
.padding(vertical = 4.dp),
|
||||
|
||||
+55
-180
@@ -3,120 +3,44 @@ package chat.simplex.common.views.helpers
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.CenteredRowLayout
|
||||
import chat.simplex.res.MR
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun DefaultAppBar(
|
||||
fun DefaultTopAppBar(
|
||||
navigationButton: (@Composable RowScope.() -> Unit)? = null,
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
fixedTitleText: String? = null,
|
||||
title: (@Composable () -> Unit)?,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
onTop: Boolean,
|
||||
showSearch: Boolean = false,
|
||||
searchAlwaysVisible: Boolean = false,
|
||||
onSearchValueChanged: (String) -> Unit = {},
|
||||
buttons: @Composable RowScope.() -> Unit = {},
|
||||
showSearch: Boolean,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
) {
|
||||
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
|
||||
val modifier = if (!showSearch) {
|
||||
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
|
||||
} else Modifier.imePadding()
|
||||
} else Modifier
|
||||
|
||||
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
|
||||
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = LocalAppBarHandler.current?.connection
|
||||
val titleText = remember(handler?.title?.value, fixedTitleText) {
|
||||
if (fixedTitleText != null) {
|
||||
mutableStateOf(fixedTitleText)
|
||||
} else {
|
||||
handler?.title ?: mutableStateOf("")
|
||||
}
|
||||
}
|
||||
val keyboardInset = WindowInsets.ime
|
||||
Box(modifier) {
|
||||
val density = LocalDensity.current
|
||||
val blurRadius = remember { appPrefs.appearanceBarsBlurRadius.state }
|
||||
Box(Modifier
|
||||
.matchParentSize()
|
||||
.blurredBackgroundModifier(keyboardInset, handler, blurRadius, prefAlpha, handler?.keyboardCoversBar == true, onTop, density)
|
||||
.drawWithCache {
|
||||
// store it as a variable, don't put it inside if without holding it here. Compiler don't see it changes otherwise
|
||||
val alpha = prefAlpha.value
|
||||
val backgroundColor = if (title != null || fixedTitleText != null || connection == null || !onTop) {
|
||||
themeBackgroundMix.copy(alpha)
|
||||
} else {
|
||||
themeBackgroundMix.copy(topTitleAlpha(false, connection))
|
||||
}
|
||||
onDrawBehind {
|
||||
drawRect(backgroundColor)
|
||||
}
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (!showSearch) {
|
||||
title?.invoke()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = false, onValueChange = onSearchValueChanged)
|
||||
}
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!onTop) Modifier.navigationBarsPadding() else Modifier)
|
||||
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
|
||||
) {
|
||||
AppBar(
|
||||
title = {
|
||||
if (showSearch) {
|
||||
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
|
||||
} else if (title != null) {
|
||||
title()
|
||||
} else if (titleText.value.isNotEmpty() && connection != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = if (fixedTitleText != null) 1f else topTitleAlpha(true, connection)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
titleText.value,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = navigationButton,
|
||||
buttons = if (!showSearch) buttons else {{}},
|
||||
centered = !showSearch && (title != null || !onTop),
|
||||
onTop = onTop,
|
||||
)
|
||||
AppBarDivider(onTop, title != null || fixedTitleText != null, connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun CallAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
AppBar(
|
||||
title,
|
||||
navigationIcon = { NavigationButtonBack(tintColor = Color(0xFFFFFFD8), onButtonClicked = onBack) },
|
||||
centered = false,
|
||||
onTop = true
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f),
|
||||
navigationIcon = navigationButton,
|
||||
buttons = if (!showSearch) buttons else emptyList(),
|
||||
centered = !showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,107 +83,58 @@ fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.AppBarDivider(onTop: Boolean, fixedAlpha: Boolean, connection: CollapsingAppBarNestedScrollConnection?) {
|
||||
if (connection != null) {
|
||||
Divider(
|
||||
Modifier
|
||||
.align(if (onTop) Alignment.BottomStart else Alignment.TopStart)
|
||||
.graphicsLayer {
|
||||
alpha = if (!onTop || fixedAlpha) 1f else topTitleAlpha(false, connection, 1f)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Divider(Modifier.align(if (onTop) Alignment.BottomStart else Alignment.TopStart))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppBar(
|
||||
private fun TopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
|
||||
buttons: @Composable RowScope.() -> Unit = {},
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
backgroundColor: Color = MaterialTheme.colors.primarySurface,
|
||||
centered: Boolean,
|
||||
onTop: Boolean,
|
||||
) {
|
||||
val adjustedModifier = modifier
|
||||
.then(if (onTop) Modifier.statusBarsPadding() else Modifier)
|
||||
.height(AppBarHeight * fontSizeSqrtMultiplier)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppBarHorizontalPadding)
|
||||
if (centered) {
|
||||
AppBarCenterAligned(adjustedModifier, title, navigationIcon, buttons)
|
||||
} else {
|
||||
AppBarStartAligned(adjustedModifier, title, navigationIcon, buttons)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppBarStartAligned(
|
||||
modifier: Modifier,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
|
||||
buttons: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(AppBarHeight * fontSizeSqrtMultiplier)
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (navigationIcon != null) {
|
||||
navigationIcon()
|
||||
Spacer(Modifier.width(AppBarHorizontalPadding))
|
||||
} else {
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
}
|
||||
Row(Modifier
|
||||
.weight(1f)
|
||||
.padding(end = DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
title()
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppBarCenterAligned(
|
||||
modifier: Modifier,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
|
||||
buttons: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
CenteredRowLayout(modifier) {
|
||||
if (navigationIcon != null) {
|
||||
Row(
|
||||
Modifier.padding(end = AppBarHorizontalPadding),
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = navigationIcon
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
Row(
|
||||
Modifier.padding(end = DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
title()
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
buttons()
|
||||
buttons.forEach { it() }
|
||||
}
|
||||
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
|
||||
val endPadding = (buttons.size * 50f).dp
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
|
||||
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
title()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
|
||||
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
|
||||
val AppBarHorizontalPadding = 2.dp
|
||||
val AppBarHorizontalPadding = 4.dp
|
||||
val BottomAppBarHeight = 60.dp
|
||||
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
|
||||
val TitleInsetWithIcon = 72.dp
|
||||
|
||||
+2
-2
@@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -106,7 +107,6 @@ fun <T> ExposedDropDownSettingWithIcon(
|
||||
expanded.value = !expanded.value && enabled.value
|
||||
}
|
||||
) {
|
||||
val ripple = remember { ripple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(background, CircleShape)
|
||||
@@ -115,7 +115,7 @@ fun <T> ExposedDropDownSettingWithIcon(
|
||||
onClick = {},
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple,
|
||||
indication = rememberRipple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)),
|
||||
enabled = enabled.value
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
|
||||
+24
-36
@@ -6,14 +6,12 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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 chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.StatusBarBackground
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.min
|
||||
@@ -23,40 +21,24 @@ import kotlin.math.sqrt
|
||||
fun ModalView(
|
||||
close: () -> Unit,
|
||||
showClose: Boolean = true,
|
||||
showAppBar: Boolean = true,
|
||||
enableClose: Boolean = true,
|
||||
background: Color = Color.Unspecified,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier,
|
||||
showSearch: Boolean = false,
|
||||
searchAlwaysVisible: Boolean = false,
|
||||
onSearchValueChanged: (String) -> Unit = {},
|
||||
closeOnTop: Boolean = true,
|
||||
endButtons: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (showClose && showAppBar) {
|
||||
if (showClose) {
|
||||
BackHandler(enabled = enableClose, onBack = close)
|
||||
}
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
|
||||
Box(if (background != Color.Unspecified) Modifier.background(background) else Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
|
||||
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
||||
if (closeOnTop) {
|
||||
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
content()
|
||||
}
|
||||
if (showAppBar) {
|
||||
if (oneHandUI.value) {
|
||||
StatusBarBackground()
|
||||
}
|
||||
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
|
||||
DefaultAppBar(
|
||||
navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null,
|
||||
onTop = !oneHandUI.value,
|
||||
showSearch = showSearch,
|
||||
searchAlwaysVisible = searchAlwaysVisible,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = endButtons
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +47,7 @@ enum class ModalPlacement {
|
||||
START, CENTER, END, FULLSCREEN
|
||||
}
|
||||
|
||||
class ModalData(val keyboardCoversBar: Boolean = true) {
|
||||
class ModalData() {
|
||||
private val state = mutableMapOf<String, MutableState<Any?>>()
|
||||
fun <T> stateGetOrPut (key: String, default: () -> T): MutableState<T> =
|
||||
state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState<T>
|
||||
@@ -73,7 +55,7 @@ class ModalData(val keyboardCoversBar: Boolean = true) {
|
||||
fun <T> stateGetOrPutNullable (key: String, default: () -> T?): MutableState<T?> =
|
||||
state.getOrPut(key) { mutableStateOf(default() as Any?) } as MutableState<T?>
|
||||
|
||||
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
|
||||
val appBarHandler = AppBarHandler()
|
||||
}
|
||||
|
||||
class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
@@ -87,21 +69,23 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
|
||||
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
|
||||
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
|
||||
val data = ModalData()
|
||||
showCustomModal { close ->
|
||||
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
|
||||
ModalView(close, showClose = showClose, closeOnTop = closeOnTop, endButtons = endButtons, content = { data.content() })
|
||||
}
|
||||
}
|
||||
|
||||
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
val data = ModalData()
|
||||
showCustomModal { close ->
|
||||
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
|
||||
ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, content = { data.content(close) })
|
||||
}
|
||||
}
|
||||
|
||||
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
fun showCustomModal(animated: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showCustomModal")
|
||||
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
|
||||
val data = ModalData()
|
||||
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
|
||||
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
|
||||
if (toRemove.isNotEmpty()) {
|
||||
@@ -162,7 +146,9 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
// Without animation
|
||||
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
|
||||
modalViews.lastOrNull()?.let {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides it.second.appBarHandler
|
||||
) {
|
||||
it.third(it.second, ::closeModal)
|
||||
}
|
||||
}
|
||||
@@ -178,7 +164,9 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
}
|
||||
) {
|
||||
modalViews.getOrNull(it - 1)?.let {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides it.second.appBarHandler
|
||||
) {
|
||||
it.third(it.second, ::closeModal)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -2,7 +2,7 @@ package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
@@ -18,9 +18,12 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -35,7 +38,6 @@ fun SearchTextField(
|
||||
placeholder: String = stringResource(MR.strings.search_verb),
|
||||
enabled: Boolean = true,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
reducedCloseButtonPadding: Dp = 0.dp,
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -79,20 +81,15 @@ fun SearchTextField(
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
// sizing is done differently on Android and desktop in order to have the same height of search and compose view on desktop
|
||||
// see PlatformTextField.desktop + SendMsgView
|
||||
val padding = if (appPlatform.isAndroid) PaddingValues() else PaddingValues(top = 3.dp, bottom = 4.dp)
|
||||
BasicTextField(
|
||||
value = searchText.value,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.padding(padding)
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = if (appPlatform.isAndroid) TextFieldDefaults.MinHeight else 0.dp
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
searchText.value = it
|
||||
@@ -103,14 +100,18 @@ fun SearchTextField(
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
singleLine = true,
|
||||
textStyle = textStyle,
|
||||
textStyle = TextStyle(
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = searchText.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(placeholder, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
trailingIcon = if (searchText.value.text.isNotEmpty()) {{
|
||||
IconButton({
|
||||
@@ -120,7 +121,7 @@ fun SearchTextField(
|
||||
}
|
||||
searchText.value = TextFieldValue("");
|
||||
onValueChange("")
|
||||
}, Modifier.offset(x = reducedCloseButtonPadding)) {
|
||||
}) {
|
||||
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else trailingContent,
|
||||
|
||||
+1
-1
@@ -57,6 +57,7 @@ fun TextEditor(
|
||||
) {
|
||||
val textFieldModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsWithImePadding()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.padding(10.dp)
|
||||
|
||||
@@ -86,7 +87,6 @@ fun TextEditor(
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+8
-2
@@ -32,7 +32,10 @@ fun ModalData.UserWallpaperEditor(
|
||||
globalThemeUsed: MutableState<Boolean>,
|
||||
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
|
||||
var showMore by remember { stateGetOrPut("showMore") { false } }
|
||||
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
|
||||
@@ -228,7 +231,10 @@ fun ModalData.ChatWallpaperEditor(
|
||||
globalThemeUsed: MutableState<Boolean>,
|
||||
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
|
||||
var showMore by remember { stateGetOrPut("showMore") { false } }
|
||||
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
|
||||
|
||||
+3
-1
@@ -149,7 +149,9 @@ private fun MigrateFromDeviceLayout(
|
||||
) {
|
||||
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
|
||||
|
||||
ColumnWithScrollBar(maxIntrinsicSize = true) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(), maxIntrinsicSize = true
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.migrate_from_device_title))
|
||||
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver)
|
||||
SectionBottomSpacer()
|
||||
|
||||
+3
-1
@@ -162,7 +162,9 @@ private fun ModalData.MigrateToDeviceLayout(
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
|
||||
ColumnWithScrollBar(maxIntrinsicSize = true) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(), maxIntrinsicSize = true
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.migrate_to_device_title))
|
||||
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close)
|
||||
SectionBottomSpacer()
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun AddContactLearnMore(close: () -> Unit) {
|
||||
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.one_time_link), withPadding = false)
|
||||
ReadableText(MR.strings.scan_qr_to_connect_to_contact)
|
||||
ReadableText(MR.strings.if_you_cant_meet_in_person)
|
||||
|
||||
+10
-6
@@ -84,9 +84,10 @@ fun AddGroupLayout(
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val incognito = remember { mutableStateOf(incognitoPref.get()) }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.imePadding(),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
@@ -99,7 +100,11 @@ fun AddGroupLayout(
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId))
|
||||
Box(
|
||||
Modifier
|
||||
@@ -117,7 +122,7 @@ fun AddGroupLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.group_display_name_field),
|
||||
fontSize = 16.sp
|
||||
@@ -129,9 +134,7 @@ fun AddGroupLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester)
|
||||
}
|
||||
ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
SettingsActionItem(
|
||||
@@ -167,6 +170,7 @@ fun AddGroupLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim())
|
||||
|
||||
+4
-2
@@ -89,7 +89,7 @@ private fun ContactConnectionInfoLayout(
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_theater_comedy_filled),
|
||||
text = null,
|
||||
click = { ModalManager.end.showModal { IncognitoView() } },
|
||||
click = { ModalManager.start.showModal { IncognitoView() } },
|
||||
iconColor = Indigo,
|
||||
extraPadding = false
|
||||
) {
|
||||
@@ -105,7 +105,9 @@ private fun ContactConnectionInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
ColumnWithScrollBar(
|
||||
Modifier,
|
||||
) {
|
||||
AppBarTitle(
|
||||
stringResource(
|
||||
if (contactConnection.initiated) MR.strings.you_invited_a_contact
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user