Compare commits
151 Commits
v4.2.0
...
v4.3.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e6e9ad5e2 | ||
|
|
e6c5ad5833 | ||
|
|
8af0229f52 | ||
|
|
7f0355ec67 | ||
|
|
ade8c97b16 | ||
|
|
ee18bce964 | ||
|
|
7e204127b8 | ||
|
|
5619152810 | ||
|
|
6f463c16a5 | ||
|
|
33b3557950 | ||
|
|
e5912e58f5 | ||
|
|
7a0d2add17 | ||
|
|
ac30602a50 | ||
|
|
6cc4e2e801 | ||
|
|
5650898c2c | ||
|
|
336a170b3a | ||
|
|
9c06acd4bc | ||
|
|
1d819a4af3 | ||
|
|
5263698e64 | ||
|
|
5d0d9a1c18 | ||
|
|
7407884223 | ||
|
|
07acbfe743 | ||
|
|
5e2c868612 | ||
|
|
f6ed099f17 | ||
|
|
098cbf33b6 | ||
|
|
f8cf35879f | ||
|
|
60fedbf5d2 | ||
|
|
87d306383c | ||
|
|
18b772a80b | ||
|
|
789c54bd5f | ||
|
|
f8214b0604 | ||
|
|
2eec81c35e | ||
|
|
eb099c526a | ||
|
|
e18bb74bfd | ||
|
|
9225f437e9 | ||
|
|
de7548a9a8 | ||
|
|
a58a0fae29 | ||
|
|
e32b24ef70 | ||
|
|
5d73b364d8 | ||
|
|
fa2f303547 | ||
|
|
a6e4e68bc5 | ||
|
|
4485d46307 | ||
|
|
a7345ee4d9 | ||
|
|
388aaec80b | ||
|
|
21722b3417 | ||
|
|
e6e5faeb9c | ||
|
|
67d78e14be | ||
|
|
0d1a70af34 | ||
|
|
2b09fb425d | ||
|
|
ab91c54080 | ||
|
|
c7f70f0ed0 | ||
|
|
5806a2ceb4 | ||
|
|
6b71cc59c8 | ||
|
|
33a866463d | ||
|
|
fb165622aa | ||
|
|
7e3d53b621 | ||
|
|
7544d2f9e7 | ||
|
|
02fa81e8aa | ||
|
|
4296b6c622 | ||
|
|
b5652bce81 | ||
|
|
b8298aa458 | ||
|
|
c3244f1b76 | ||
|
|
0ad74d9538 | ||
|
|
a4be68f4bd | ||
|
|
0cb8f8ad82 | ||
|
|
9d7bb06396 | ||
|
|
a8b9200c9a | ||
|
|
a9c2a7dcaa | ||
|
|
38b28f866c | ||
|
|
bfa7ff16ff | ||
|
|
5c2b70a214 | ||
|
|
7e3f91f87c | ||
|
|
f54faebff3 | ||
|
|
4e5aa3dcbc | ||
|
|
56f3874a93 | ||
|
|
828b502431 | ||
|
|
491fe4a9bf | ||
|
|
f8302e2030 | ||
|
|
fd34c39552 | ||
|
|
b1fa1a84fe | ||
|
|
cf23399262 | ||
|
|
b5a812769b | ||
|
|
40e1b01baf | ||
|
|
9c925ab040 | ||
|
|
faceeb6fce | ||
|
|
07e8c1d76e | ||
|
|
b1d8600215 | ||
|
|
e14ab0fed0 | ||
|
|
cb0c499f57 | ||
|
|
002a081b3b | ||
|
|
c2b76a75b5 | ||
|
|
2742fc3ca9 | ||
|
|
f3731799bc | ||
|
|
7a78dfd3e3 | ||
|
|
5a2dd7b4bc | ||
|
|
1a4d2b6de6 | ||
|
|
e4b46a45d3 | ||
|
|
d61a7fb4d8 | ||
|
|
bddb37593c | ||
|
|
8b794b2285 | ||
|
|
8d0ec01a9b | ||
|
|
d85aa655cb | ||
|
|
ba0cffb511 | ||
|
|
d029ce9817 | ||
|
|
678f4f5e87 | ||
|
|
b780a41272 | ||
|
|
1caaca83cb | ||
|
|
c8b2bcb064 | ||
|
|
adfe20b54c | ||
|
|
b9d625da18 | ||
|
|
8631cf1471 | ||
|
|
0b6b8bd327 | ||
|
|
e83ed30a49 | ||
|
|
29f919b3d6 | ||
|
|
2636f2ce1c | ||
|
|
f80f56de61 | ||
|
|
941660625d | ||
|
|
ad1432e0ee | ||
|
|
1cfbbd3115 | ||
|
|
21ffe0ad49 | ||
|
|
54ad071655 | ||
|
|
992c934fd1 | ||
|
|
a6c6f1dbff | ||
|
|
a652a14d58 | ||
|
|
9e77f05e58 | ||
|
|
355a3c429c | ||
|
|
a1ce3b9c69 | ||
|
|
00af82cb19 | ||
|
|
68b6d9e966 | ||
|
|
75165cc70a | ||
|
|
e5bf5092b1 | ||
|
|
e5a4cca5e0 | ||
|
|
41f4f11155 | ||
|
|
99bfd446d1 | ||
|
|
dd740e82cf | ||
|
|
5fabeff1fa | ||
|
|
324730f8ae | ||
|
|
ed1faff500 | ||
|
|
ce7d0ab8cf | ||
|
|
75dccf95c4 | ||
|
|
fa5a70cd19 | ||
|
|
dd9e94eefd | ||
|
|
0f65a001c8 | ||
|
|
f3e59aa3c3 | ||
|
|
655041c657 | ||
|
|
4ca118666a | ||
|
|
365f92e958 | ||
|
|
ddecd847e5 | ||
|
|
18677cec63 | ||
|
|
4e8dcab020 | ||
|
|
eb0f78bd80 |
34
Dockerfile
34
Dockerfile
@@ -1,10 +1,32 @@
|
||||
FROM haskell:8.10.4 AS build-stage
|
||||
# if you encounter "version `GLIBC_2.28' not found" error when running
|
||||
# chat client executable, build with the following base image instead:
|
||||
# FROM haskell:8.10.4-stretch AS build-stage
|
||||
FROM ubuntu:focal AS build
|
||||
|
||||
# Install curl and simplex-chat-related dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
||||
|
||||
# Install ghcup
|
||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 8.10.7
|
||||
# Install cabal
|
||||
RUN ghcup install cabal
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 8.10.7 && \
|
||||
ghcup set cabal
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
RUN stack install
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Adjust build
|
||||
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal install
|
||||
|
||||
FROM scratch AS export-stage
|
||||
COPY --from=build-stage /root/.local/bin/simplex-chat /
|
||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||
|
||||
18
PRIVACY.md
18
PRIVACY.md
@@ -1,22 +1,26 @@
|
||||
# SimpleX Chat Terms & Privacy Policy
|
||||
|
||||
SimpleX Chat is the first chat platform that is 100% private by design - not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we do not have access to your connections graph.
|
||||
SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph.
|
||||
|
||||
If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Information you provide
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are temporarily offline. Your message history is stored only on your own devices.
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
|
||||
|
||||
Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
|
||||
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||
|
||||
### Information we may share
|
||||
|
||||
@@ -31,6 +35,8 @@ The cases when SimpleX Chat may need to share the data we temporarily store on t
|
||||
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
||||
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
|
||||
|
||||
At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process.
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
@@ -47,7 +53,7 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per users - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
|
||||
|
||||
@@ -87,4 +93,4 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated March 1, 2022
|
||||
Updated November 8, 2022
|
||||
|
||||
101
README.md
101
README.md
@@ -16,15 +16,15 @@
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
|
||||
|
||||
- 🖲 Protects your messages and metadata - who you talk to and when.
|
||||
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
||||
|
||||
**NEW**: v4.0 is released - now local chat database is encrypted with passphrase! See [the release announcement](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -42,8 +42,10 @@
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
|
||||
- [Disclaimer, License](#disclaimer)
|
||||
- [Join a user group](#join-a-user-group)
|
||||
- [Contribute](#contribute)
|
||||
- [Help us with donations](#help-us-with-donations)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
|
||||
## Why privacy matters
|
||||
|
||||
@@ -83,6 +85,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
|
||||
|
||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
||||
@@ -149,7 +153,6 @@ We plan to add soon:
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
4. Independent implementation audit.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -175,42 +178,69 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Manual chat history deletion.
|
||||
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
|
||||
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
|
||||
- ✅ Chat database export and import
|
||||
- ✅ Chat database export and import.
|
||||
- ✅ Chat groups in mobile apps.
|
||||
- ✅ Connecting to messaging servers via Tor.
|
||||
- ✅ Dual server addresses to access messaging servers as v3 hidden services.
|
||||
- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
|
||||
- ✅ Incognito mode to share a new random name with each contact.
|
||||
- ✅ Chat database encryption.
|
||||
- 🏗 Automatic chat history deletion.
|
||||
- 🏗 SMP queue redundancy and rotation.
|
||||
- 🏗 Links to join groups and improve groups stability.
|
||||
- Feeds/broadcasts
|
||||
- Disappearing messages, with mutual agreement.
|
||||
- Voice messages
|
||||
- Video messages
|
||||
- ✅ Automatic chat history deletion.
|
||||
- ✅ Links to join groups and improve groups stability.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Voice messages (with recipient opt-out per contact).
|
||||
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
|
||||
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||
- Block screenshots and view in recent apps.
|
||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Video messages.
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Multiple user profiles in the same chat database.
|
||||
- Advanced server configuration.
|
||||
- Feeds/broadcasts.
|
||||
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Message delivery confirmation.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Desktop client.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Desktop client.
|
||||
- Using the same profile on multiple devices.
|
||||
|
||||
## Help us pay for 3rd party security audit
|
||||
## Join a user group
|
||||
|
||||
I will get straight to the point: I ask you to support SimpleX Chat with donations.
|
||||
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D).
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
|
||||
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
|
||||
|
||||
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
|
||||
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
|
||||
|
||||
Let us know if you'd like to add some other countries to the list.
|
||||
|
||||
Join via the app to share what's going on and ask any questions!
|
||||
|
||||
## Contribute
|
||||
|
||||
We would love to have you join the development! You can contribute to SimpleX Chat with:
|
||||
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
|
||||
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
@@ -218,6 +248,7 @@ It is possible to donate via:
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
||||
|
||||
Thank you,
|
||||
|
||||
@@ -225,11 +256,27 @@ Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
|
||||
## Disclaimer
|
||||
## Disclaimers
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||
|
||||
You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
|
||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
|
||||
|
||||
The default servers configured in the app are provided on the best effort basis. We are currently not guaranteeing any SLAs, although historically our servers had over 99.9% uptime each.
|
||||
|
||||
We have never provided or have been requested access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will be following due legal process.
|
||||
|
||||
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
|
||||
|
||||
Please read more in [Terms & privacy policy](./PRIVACY.md).
|
||||
|
||||
## Security contact
|
||||
|
||||
To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
|
||||
|
||||
Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
|
||||
|
||||
## License
|
||||
|
||||
@@ -243,4 +290,4 @@ You are likely to discover some bugs - we would really appreciate if you use it
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 67
|
||||
versionName "4.2"
|
||||
versionCode 73
|
||||
versionName "4.3-beta.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -29,6 +29,7 @@ extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||
extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
||||
@@ -76,3 +77,11 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,23 @@ import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Replay
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
@@ -33,10 +37,12 @@ import chat.simplex.app.views.chat.ChatView
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.connectViaUri
|
||||
import chat.simplex.app.views.newchat.withUriAction
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
@@ -68,6 +74,13 @@ class MainActivity: FragmentActivity() {
|
||||
processIntent(intent, m)
|
||||
processExternalIntent(intent, m)
|
||||
}
|
||||
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
}
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
@@ -104,6 +117,16 @@ class MainActivity: FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
/**
|
||||
* When new activity is created after a click on notification, the old one receives onPause before
|
||||
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
|
||||
* unwanted multiple auth dialogs from [runAuthenticate]
|
||||
* */
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
@@ -135,17 +158,10 @@ class MainActivity: FragmentActivity() {
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
is LAResult.Error, LAResult.Failed ->
|
||||
laFailed.value = true
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
laFailed.value = true
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
@@ -181,15 +197,9 @@ class MainActivity: FragmentActivity() {
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
@@ -214,15 +224,9 @@ class MainActivity: FragmentActivity() {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
@@ -289,14 +293,14 @@ fun MainPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun retryAuthView() {
|
||||
fun authView() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_retry),
|
||||
icon = Icons.Outlined.Replay,
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.Lock,
|
||||
click = {
|
||||
laFailed.value = false
|
||||
runAuthenticate()
|
||||
@@ -317,7 +321,7 @@ fun MainPage(
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
!chatsAccessAuthorized -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
retryAuthView()
|
||||
authView()
|
||||
} else {
|
||||
SplashView()
|
||||
}
|
||||
@@ -327,14 +331,65 @@ fun MainPage(
|
||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||
else {
|
||||
showAdvertiseLAAlert = true
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.chatId.value == null) {
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
BoxWithConstraints {
|
||||
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
) {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val onComposed: () -> Unit = {
|
||||
scope.launch {
|
||||
offset.animateTo(
|
||||
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||
chatListAnimationSpec()
|
||||
)
|
||||
if (offset.value == 0f) {
|
||||
currentChatId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it != null) currentChatId = it
|
||||
else onComposed()
|
||||
|
||||
// Deletes files that were not sent but already stored in files directory.
|
||||
// Currently, it's voice records only
|
||||
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
|
||||
chatModel.filesToDelete.forEach { it.delete() }
|
||||
chatModel.filesToDelete.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
snapshotFlow { chatModel.sharedContent.value }
|
||||
.distinctUntilChanged()
|
||||
.filter { it != null }
|
||||
.collect {
|
||||
chatModel.chatId.value = null
|
||||
currentChatId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||
currentChatId?.let {
|
||||
ChatView(it, chatModel, onComposed)
|
||||
}
|
||||
}
|
||||
}
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,23 +481,23 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { action ->
|
||||
val title = when (action) {
|
||||
"contact" -> generalGetString(R.string.connect_via_contact_link)
|
||||
"invitation" -> generalGetString(R.string.connect_via_invitation_link)
|
||||
else -> {
|
||||
Log.e(TAG, "URI has unexpected action. Alert shown.")
|
||||
action
|
||||
}
|
||||
withUriAction(uri) { linkType ->
|
||||
val title = when (linkType) {
|
||||
ConnectionLinkType.CONTACT -> generalGetString(R.string.connect_via_contact_link)
|
||||
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
||||
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = title,
|
||||
text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
|
||||
text = if (linkType == ConnectionLinkType.GROUP)
|
||||
generalGetString(R.string.you_will_join_group)
|
||||
else
|
||||
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
|
||||
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, action, uri)
|
||||
connectViaUri(chatModel, linkType, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
lateinit var chatController: ChatController
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private var isStartingService = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
@@ -48,11 +46,32 @@ class SimplexService: Service() {
|
||||
notificationManager = createNotificationChannel()
|
||||
serviceNotification = createNotification(title, text)
|
||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||
/**
|
||||
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||
* */
|
||||
if (stopAfterStart) {
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} else {
|
||||
isServiceStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
}
|
||||
wakeLock = null
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||
}
|
||||
isServiceStarted = false
|
||||
stopAfterStart = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
|
||||
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||
@@ -62,7 +81,7 @@ class SimplexService: Service() {
|
||||
|
||||
private fun startService() {
|
||||
Log.d(TAG, "SimplexService startService")
|
||||
if (isServiceStarted || isStartingService) return
|
||||
if (wakeLock != null || isStartingService) return
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
@@ -73,10 +92,9 @@ class SimplexService: Service() {
|
||||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||
showPassphraseNotification(chatDbStatus)
|
||||
stopService()
|
||||
safeStopService(self)
|
||||
return@withApi
|
||||
}
|
||||
isServiceStarted = true
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
@@ -89,22 +107,6 @@ class SimplexService: Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
Log.d(TAG, "Stopping foreground service")
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
}
|
||||
wakeLock = null
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||
}
|
||||
isServiceStarted = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -235,6 +237,9 @@ class SimplexService: Service() {
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
private var isServiceStarted = false
|
||||
private var stopAfterStart = false
|
||||
|
||||
fun scheduleStart(context: Context) {
|
||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
@@ -244,7 +249,17 @@ class SimplexService: Service() {
|
||||
|
||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||
|
||||
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
|
||||
/**
|
||||
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||
* exception related to foreground services lifecycle
|
||||
* */
|
||||
fun safeStopService(context: Context) {
|
||||
if (isServiceStarted) {
|
||||
context.stopService(Intent(context, SimplexService::class.java))
|
||||
} else {
|
||||
stopAfterStart = true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
|
||||
@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
|
||||
/*
|
||||
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
|
||||
* */
|
||||
@Stable
|
||||
class ChatModel(val controller: ChatController) {
|
||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||
val currentUser = mutableStateOf<User?>(null)
|
||||
@@ -39,7 +44,10 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
||||
val userSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||
// Allows to temporary save servers that are being edited on multiple screens
|
||||
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||
|
||||
// set when app opened from external intent
|
||||
@@ -70,6 +78,9 @@ class ChatModel(val controller: ChatController) {
|
||||
// working with external intents
|
||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||
|
||||
fun updateUserProfile(profile: LocalProfile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
@@ -218,6 +229,7 @@ class ChatModel(val controller: ChatController) {
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
AudioPlayer.stop(chatItems[itemIndex])
|
||||
chatItems.removeAt(itemIndex)
|
||||
}
|
||||
}
|
||||
@@ -307,16 +319,17 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
|
||||
fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
|
||||
// user member was updated
|
||||
if (groupInfo.membership.groupMemberId == member.groupMemberId) {
|
||||
updateGroup(groupInfo)
|
||||
return false
|
||||
}
|
||||
// update current chat
|
||||
return if (chatId.value == groupInfo.id) {
|
||||
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
|
||||
if (memberIndex >= 0) {
|
||||
groupMembers[memberIndex] = member
|
||||
false
|
||||
} else if (groupInfo.membership.groupMemberId == member.groupMemberId) {
|
||||
// Current user was updated (like his role, for example)
|
||||
updateChatInfo(ChatInfo.Group(groupInfo))
|
||||
true
|
||||
} else {
|
||||
groupMembers.add(member)
|
||||
true
|
||||
@@ -340,6 +353,7 @@ data class User(
|
||||
val userContactId: Long,
|
||||
val localDisplayName: String,
|
||||
val profile: LocalProfile,
|
||||
val fullPreferences: FullChatPreferences,
|
||||
val activeUser: Boolean
|
||||
): NamedChat {
|
||||
override val displayName: String get() = profile.displayName
|
||||
@@ -353,6 +367,7 @@ data class User(
|
||||
userContactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true
|
||||
)
|
||||
}
|
||||
@@ -381,7 +396,7 @@ interface SomeChat {
|
||||
val updatedAt: Instant
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Serializable @Stable
|
||||
data class Chat (
|
||||
val chatInfo: ChatInfo,
|
||||
val chatItems: List<ChatItem>,
|
||||
@@ -430,7 +445,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
abstract val incognito: Boolean
|
||||
|
||||
@Serializable @SerialName("direct")
|
||||
class Direct(val contact: Contact): ChatInfo() {
|
||||
data class Direct(val contact: Contact): ChatInfo() {
|
||||
override val chatType get() = ChatType.Direct
|
||||
override val localDisplayName get() = contact.localDisplayName
|
||||
override val id get() = contact.id
|
||||
@@ -527,8 +542,8 @@ data class Contact(
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val chatSettings: ChatSettings,
|
||||
// User applies his preferences for the contact here. Named user_preferences on the contact in DB
|
||||
val userPreferences: ChatPreferences,
|
||||
val mergedPreferences: ContactUserPreferences,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -559,7 +574,8 @@ data class Contact(
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
chatSettings = ChatSettings(true),
|
||||
userPreferences = ChatPreferences(),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -589,12 +605,11 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Profile(
|
||||
data class Profile(
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null,
|
||||
override val localAlias : String = "",
|
||||
// Contact applies his preferences here
|
||||
val preferences: ChatPreferences? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String
|
||||
@@ -602,7 +617,7 @@ class Profile(
|
||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||
}
|
||||
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences)
|
||||
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
@@ -619,18 +634,18 @@ class LocalProfile(
|
||||
override val fullName: String,
|
||||
override val image: String? = null,
|
||||
override val localAlias: String,
|
||||
// Contact applies his preferences here
|
||||
val preferences: ChatPreferences? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences)
|
||||
|
||||
companion object {
|
||||
val sampleData = LocalProfile(
|
||||
profileId = 1L,
|
||||
displayName = "alice",
|
||||
fullName = "Alice",
|
||||
preferences = ChatPreferences.sampleData,
|
||||
localAlias = ""
|
||||
)
|
||||
}
|
||||
@@ -647,10 +662,10 @@ data class GroupInfo (
|
||||
val groupId: Long,
|
||||
override val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val fullGroupPreferences: FullGroupPreferences,
|
||||
val membership: GroupMember,
|
||||
val hostConnCustomUserProfileId: Long? = null,
|
||||
val chatSettings: ChatSettings,
|
||||
// val groupPreferences: GroupPreferences? = null,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -679,6 +694,7 @@ data class GroupInfo (
|
||||
groupId = 1,
|
||||
localDisplayName = "team",
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
fullGroupPreferences = FullGroupPreferences.sampleData,
|
||||
membership = GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId = null,
|
||||
chatSettings = ChatSettings(true),
|
||||
@@ -689,11 +705,12 @@ data class GroupInfo (
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GroupProfile (
|
||||
data class GroupProfile (
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null,
|
||||
override val localAlias: String = "",
|
||||
val groupPreferences: GroupPreferences? = null
|
||||
): NamedChat {
|
||||
companion object {
|
||||
val sampleData = GroupProfile(
|
||||
@@ -1014,7 +1031,7 @@ class AChatItem (
|
||||
val chatItem: ChatItem
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Serializable @Stable
|
||||
data class ChatItem (
|
||||
val chatDir: CIDirection,
|
||||
val meta: CIMeta,
|
||||
@@ -1028,6 +1045,9 @@ data class ChatItem (
|
||||
|
||||
val text: String get() =
|
||||
when {
|
||||
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
|
||||
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
|
||||
}
|
||||
content.text == "" && file != null -> file.fileName
|
||||
else -> content.text
|
||||
}
|
||||
@@ -1141,6 +1161,17 @@ data class ChatItem (
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
fun getChatFeatureSample(feature: Feature, enabled: FeatureEnabled): ChatItem {
|
||||
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
|
||||
return ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = content,
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,22 +1268,32 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: Feature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
|
||||
override val text: String get() = when(this) {
|
||||
is SndMsgContent -> msgContent.text
|
||||
is RcvMsgContent -> msgContent.text
|
||||
is SndDeleted -> generalGetString(R.string.deleted_description)
|
||||
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
||||
is SndCall -> status.text(duration)
|
||||
is RcvCall -> status.text(duration)
|
||||
is RcvIntegrityError -> msgError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
is SndConnEventContent -> sndConnEvent.text
|
||||
}
|
||||
override val text: String get() = when (this) {
|
||||
is SndMsgContent -> msgContent.text
|
||||
is RcvMsgContent -> msgContent.text
|
||||
is SndDeleted -> generalGetString(R.string.deleted_description)
|
||||
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
||||
is SndCall -> status.text(duration)
|
||||
is RcvCall -> status.text(duration)
|
||||
is RcvIntegrityError -> msgError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
is SndConnEventContent -> sndConnEvent.text
|
||||
is RcvChatFeature -> "${feature.text()}: ${enabled.text}"
|
||||
is SndChatFeature -> "${feature.text()}: ${enabled.text}"
|
||||
is RcvGroupFeature -> "${feature.text()}: ${preference.enable.text}"
|
||||
is SndGroupFeature -> "${feature.text()}: ${preference.enable.text}"
|
||||
is RcvChatFeatureRejected -> "${feature.text()}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1264,7 +1305,13 @@ class CIQuote (
|
||||
val content: MsgContent,
|
||||
val formattedText: List<FormattedText>? = null
|
||||
): ItemContent {
|
||||
override val text: String get() = content.text
|
||||
override val text: String by lazy {
|
||||
if (content is MsgContent.MCVoice && content.text.isEmpty())
|
||||
content.toTextWithDuration(true)
|
||||
else
|
||||
content.text
|
||||
}
|
||||
|
||||
|
||||
fun sender(membership: GroupMember?): String? = when (chatDir) {
|
||||
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
|
||||
@@ -1333,6 +1380,7 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
@@ -1340,11 +1388,17 @@ sealed class MsgContent {
|
||||
is MCText -> "text $text"
|
||||
is MCLink -> "json ${json.encodeToString(this)}"
|
||||
is MCImage -> "json ${json.encodeToString(this)}"
|
||||
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||
is MCFile -> "json ${json.encodeToString(this)}"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
}
|
||||
|
||||
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
|
||||
val time = durationToString(duration)
|
||||
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIGroupInvitation (
|
||||
val groupId: Long,
|
||||
@@ -1413,6 +1467,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
MsgContent.MCImage(text, image)
|
||||
}
|
||||
"voice" -> {
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVoice(text, duration)
|
||||
}
|
||||
"file" -> MsgContent.MCFile(text)
|
||||
else -> MsgContent.MCUnknown(t, text, json)
|
||||
}
|
||||
@@ -1444,6 +1502,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
}
|
||||
is MsgContent.MCVoice ->
|
||||
buildJsonObject {
|
||||
put("type", "voice")
|
||||
put("text", value.text)
|
||||
put("duration", value.duration)
|
||||
}
|
||||
is MsgContent.MCFile ->
|
||||
buildJsonObject {
|
||||
put("type", "file")
|
||||
@@ -1457,12 +1521,21 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
|
||||
@Serializable
|
||||
class FormattedText(val text: String, val format: Format? = null) {
|
||||
val link: String? = when (format) {
|
||||
// TODO make it dependent on simplexLinkMode preference
|
||||
fun link(mode: SimplexLinkMode): String? = when (format) {
|
||||
is Format.Uri -> text
|
||||
is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
|
||||
is Format.Email -> "mailto:$text"
|
||||
is Format.Phone -> "tel:$text"
|
||||
else -> null
|
||||
}
|
||||
|
||||
// TODO make it dependent on simplexLinkMode preference
|
||||
fun viewText(mode: SimplexLinkMode): String =
|
||||
if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
|
||||
|
||||
fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List<String>): String =
|
||||
"${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1474,6 +1547,7 @@ sealed class Format {
|
||||
@Serializable @SerialName("secret") class Secret: Format()
|
||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||
@Serializable @SerialName("uri") class Uri: Format()
|
||||
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format()
|
||||
@Serializable @SerialName("email") class Email: Format()
|
||||
@Serializable @SerialName("phone") class Phone: Format()
|
||||
|
||||
@@ -1485,6 +1559,7 @@ sealed class Format {
|
||||
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
||||
is Colored -> SpanStyle(color = this.color.uiColor)
|
||||
is Uri -> linkStyle
|
||||
is SimplexLink -> linkStyle
|
||||
is Email -> linkStyle
|
||||
is Phone -> linkStyle
|
||||
}
|
||||
@@ -1494,6 +1569,19 @@ sealed class Format {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SimplexLinkType(val linkType: String) {
|
||||
contact("contact"),
|
||||
invitation("invitation"),
|
||||
group("group");
|
||||
|
||||
val description: String get() = generalGetString(when (this) {
|
||||
contact -> R.string.simplex_link_contact
|
||||
invitation -> R.string.simplex_link_invitation
|
||||
group -> R.string.simplex_link_group
|
||||
})
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class FormatColor(val color: String) {
|
||||
red("red"),
|
||||
@@ -1509,7 +1597,7 @@ enum class FormatColor(val color: String) {
|
||||
red -> Color.Red
|
||||
green -> SimplexGreen
|
||||
blue -> SimplexBlue
|
||||
yellow -> Color.Yellow
|
||||
yellow -> WarningYellow
|
||||
cyan -> Color.Cyan
|
||||
magenta -> Color.Magenta
|
||||
black -> MaterialTheme.colors.onBackground
|
||||
@@ -1544,11 +1632,9 @@ enum class CICallStatus {
|
||||
Accepted -> generalGetString(R.string.callstatus_accepted)
|
||||
Negotiated -> generalGetString(R.string.callstatus_connecting)
|
||||
Progress -> generalGetString(R.string.callstatus_in_progress)
|
||||
Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec))
|
||||
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationToString(sec))
|
||||
Error -> generalGetString(R.string.callstatus_error)
|
||||
}
|
||||
|
||||
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
if (cItem.content.text != "") {
|
||||
cItem.content.text
|
||||
} else {
|
||||
cItem.file?.fileName ?: ""
|
||||
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
|
||||
}
|
||||
} else {
|
||||
var res = ""
|
||||
|
||||
@@ -12,8 +12,11 @@ import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -34,7 +37,7 @@ import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.concurrent.thread
|
||||
import java.util.Date
|
||||
|
||||
typealias ChatCtrl = Long
|
||||
|
||||
@@ -60,6 +63,16 @@ enum class CallOnLockScreen {
|
||||
}
|
||||
}
|
||||
|
||||
enum class SimplexLinkMode {
|
||||
DESCRIPTION,
|
||||
FULL,
|
||||
BROWSER;
|
||||
|
||||
companion object {
|
||||
val default = SimplexLinkMode.DESCRIPTION
|
||||
}
|
||||
}
|
||||
|
||||
class AppPreferences(val context: Context) {
|
||||
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
|
||||
@@ -74,7 +87,7 @@ class AppPreferences(val context: Context) {
|
||||
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||
val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
|
||||
private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
|
||||
val callOnLockScreen: Preference<CallOnLockScreen> = Preference(
|
||||
val callOnLockScreen: SharedPreference<CallOnLockScreen> = SharedPreference(
|
||||
get = fun(): CallOnLockScreen {
|
||||
val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
|
||||
return try {
|
||||
@@ -88,9 +101,22 @@ class AppPreferences(val context: Context) {
|
||||
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
|
||||
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
|
||||
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
|
||||
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
|
||||
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
|
||||
val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, false)
|
||||
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
|
||||
private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
|
||||
val simplexLinkMode: SharedPreference<SimplexLinkMode> = SharedPreference(
|
||||
get = fun(): SimplexLinkMode {
|
||||
val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default
|
||||
return try {
|
||||
SimplexLinkMode.valueOf(value)
|
||||
} catch (e: Error) {
|
||||
SimplexLinkMode.default
|
||||
}
|
||||
},
|
||||
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
|
||||
)
|
||||
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
|
||||
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
|
||||
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||
@@ -119,33 +145,33 @@ class AppPreferences(val context: Context) {
|
||||
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
Preference(
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkLongPreference(prefName: String, default: Long) =
|
||||
Preference(
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference<Long> {
|
||||
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference<Long> {
|
||||
val d = if (networkUseSocksProxy.get()) proxyDefault else default
|
||||
return Preference(
|
||||
return SharedPreference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, d),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mkBoolPreference(prefName: String, default: Boolean) =
|
||||
Preference(
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getBoolean(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkStrPreference(prefName: String, default: String?): Preference<String?> =
|
||||
Preference(
|
||||
private fun mkStrPreference(prefName: String, default: String?): SharedPreference<String?> =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getString(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
|
||||
)
|
||||
@@ -154,8 +180,8 @@ class AppPreferences(val context: Context) {
|
||||
* Provide `[commit] = true` to save preferences right now, not after some unknown period of time.
|
||||
* So in case of a crash this value will be saved 100%
|
||||
* */
|
||||
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): Preference<Instant?> =
|
||||
Preference(
|
||||
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference<Instant?> =
|
||||
SharedPreference(
|
||||
get = {
|
||||
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
|
||||
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
|
||||
@@ -178,9 +204,11 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
|
||||
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
|
||||
private const val SHARED_PREFS_WEBRTC_ICE_SERVERS = "WebrtcICEServers"
|
||||
private const val SHARED_PREFS_PRIVACY_PROTECT_SCREEN = "PrivacyProtectScreen"
|
||||
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
|
||||
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
|
||||
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
|
||||
@@ -235,7 +263,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||
apiSetIncognito(chatModel.incognito.value)
|
||||
chatModel.userAddress.value = apiGetUserAddress()
|
||||
chatModel.userSMPServers.value = getUserSMPServers()
|
||||
val smpServers = getUserSMPServers()
|
||||
chatModel.userSMPServers.value = smpServers?.first
|
||||
chatModel.presetSMPServers.value = smpServers?.second
|
||||
chatModel.chatItemTTL.value = getChatItemTTL()
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
@@ -244,6 +274,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
chatModel.appOpenUrl.value?.let {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(it, chatModel)
|
||||
}
|
||||
startReceiver()
|
||||
Log.d(TAG, "startChat: started")
|
||||
} else {
|
||||
@@ -260,8 +294,20 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
private fun startReceiver() {
|
||||
Log.d(TAG, "ChatController startReceiver")
|
||||
if (receiverStarted) return
|
||||
thread(name="receiver") {
|
||||
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
|
||||
receiverStarted = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (true) {
|
||||
/** Global [ctrl] can be null. It's needed for having the same [ChatModel] that already made in [ChatController] without the need
|
||||
* to change it everywhere in code after changing a database.
|
||||
* Since it can be changed in background thread, making this check to prevent NullPointerException */
|
||||
val ctrl = ctrl
|
||||
if (ctrl == null) {
|
||||
receiverStarted = false
|
||||
break
|
||||
}
|
||||
val msg = recvMsg(ctrl)
|
||||
if (msg != null) processReceivedMsg(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,26 +333,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recvMsg(ctrl: ChatCtrl): CR? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
|
||||
if (json == "") {
|
||||
null
|
||||
} else {
|
||||
val r = APIResponse.decodeStr(json).resp
|
||||
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
||||
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
||||
r
|
||||
}
|
||||
private fun recvMsg(ctrl: ChatCtrl): CR? {
|
||||
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
|
||||
return if (json == "") {
|
||||
null
|
||||
} else {
|
||||
val r = APIResponse.decodeStr(json).resp
|
||||
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
||||
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recvMspLoop() {
|
||||
val msg = recvMsg(ctrl ?: return)
|
||||
if (msg != null) processReceivedMsg(msg)
|
||||
recvMspLoop()
|
||||
}
|
||||
|
||||
suspend fun apiGetActiveUser(): User? {
|
||||
val r = sendCmd(CC.ShowActiveUser())
|
||||
if (r is CR.ActiveUser) return r.user
|
||||
@@ -417,14 +455,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun getUserSMPServers(): List<String>? {
|
||||
private suspend fun getUserSMPServers(): Pair<List<ServerCfg>, List<String>>? {
|
||||
val r = sendCmd(CC.GetUserSMPServers())
|
||||
if (r is CR.UserSMPServers) return r.smpServers
|
||||
if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
|
||||
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun setUserSMPServers(smpServers: List<String>): Boolean {
|
||||
suspend fun setUserSMPServers(smpServers: List<ServerCfg>): Boolean {
|
||||
val r = sendCmd(CC.SetUserSMPServers(smpServers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
@@ -439,6 +477,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
|
||||
val r = sendCmd(CC.TestSMPServer(smpServer))
|
||||
return when (r) {
|
||||
is CR.SmpTestResult -> r.smpTestFailure
|
||||
else -> {
|
||||
Log.e(TAG, "testSMPServer bad response: ${r.responseType} ${r.details}")
|
||||
throw Exception("testSMPServer bad response: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatItemTTL(): ChatItemTTL {
|
||||
val r = sendCmd(CC.APIGetChatItemTTL())
|
||||
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
|
||||
@@ -614,6 +663,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
|
||||
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
|
||||
if (r is CR.ContactPrefsUpdated) return r.toContact
|
||||
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? {
|
||||
val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias))
|
||||
if (r is CR.ContactAliasUpdated) return r.toContact
|
||||
@@ -628,13 +684,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
|
||||
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
|
||||
if (r is CR.ContactPrefsUpdated) return r.toContact
|
||||
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateUserAddress(): String? {
|
||||
val r = sendCmd(CC.CreateMyAddress())
|
||||
return when (r) {
|
||||
@@ -772,7 +821,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
|
||||
&& r.chatError.errorType is ChatErrorType.FileAlreadyReceiving
|
||||
) {
|
||||
Log.d(TAG, "apiReceiveFile ignoring FileAlreadyReceiving error")
|
||||
} else {
|
||||
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
@@ -1006,6 +1061,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
val file = cItem.file
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
@@ -1031,11 +1088,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
AudioPlayer.stop(cItem)
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
is CR.ReceivedGroupInvitation -> {
|
||||
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
|
||||
chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated
|
||||
// TODO NtfManager.shared.notifyGroupInvitation
|
||||
}
|
||||
is CR.UserAcceptedGroupSent -> {
|
||||
@@ -1212,7 +1270,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.notificationsMode.value = NotificationsMode.OFF
|
||||
SimplexService.StartReceiver.toggleReceiver(false)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
SimplexService.stop(SimplexApp.context)
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization(mode)
|
||||
@@ -1222,23 +1280,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
// service or periodic mode was chosen and battery optimization is disabled
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicWakeUp()
|
||||
chatModel.appOpenUrl.value?.let {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(it, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
|
||||
val hideAlert: () -> Unit = {
|
||||
AlertManager.shared.hideAlert()
|
||||
chatModel.appOpenUrl.value?.let {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(it, chatModel)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = hideAlert,
|
||||
onDismissRequest = AlertManager.shared::hideAlert,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
@@ -1264,7 +1311,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1305,15 +1352,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
|
||||
val hideAlert: () -> Unit = {
|
||||
AlertManager.shared.hideAlert()
|
||||
chatModel.appOpenUrl.value?.let {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(it, chatModel)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = hideAlert,
|
||||
onDismissRequest = AlertManager.shared::hideAlert,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
@@ -1336,7 +1376,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1361,15 +1401,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laErrorToast(appContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedToast(appContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
chatModel.performLA.value = false
|
||||
@@ -1446,7 +1480,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
|
||||
class SharedPreference<T>(val get: () -> T, val set: (T) -> Unit)
|
||||
|
||||
// ChatCommand
|
||||
sealed class CC {
|
||||
@@ -1478,7 +1512,8 @@ sealed class CC {
|
||||
class APIDeleteGroupLink(val groupId: Long): CC()
|
||||
class APIGetGroupLink(val groupId: Long): CC()
|
||||
class GetUserSMPServers: CC()
|
||||
class SetUserSMPServers(val smpServers: List<String>): CC()
|
||||
class SetUserSMPServers(val smpServers: List<ServerCfg>): CC()
|
||||
class TestSMPServer(val smpServer: String): CC()
|
||||
class APISetChatItemTTL(val seconds: Long?): CC()
|
||||
class APIGetChatItemTTL: CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
@@ -1494,10 +1529,10 @@ sealed class CC {
|
||||
class ApiClearChat(val type: ChatType, val id: Long): CC()
|
||||
class ListContacts: CC()
|
||||
class ApiUpdateProfile(val profile: Profile): CC()
|
||||
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
|
||||
class ApiParseMarkdown(val text: String): CC()
|
||||
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
|
||||
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
|
||||
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
|
||||
class CreateMyAddress: CC()
|
||||
class DeleteMyAddress: CC()
|
||||
class ShowMyAddress: CC()
|
||||
@@ -1543,8 +1578,9 @@ sealed class CC {
|
||||
is APICreateGroupLink -> "/_create link #$groupId"
|
||||
is APIDeleteGroupLink -> "/_delete link #$groupId"
|
||||
is APIGetGroupLink -> "/_get link #$groupId"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
|
||||
is GetUserSMPServers -> "/smp"
|
||||
is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}"
|
||||
is TestSMPServer -> "/smp test $smpServer"
|
||||
is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}"
|
||||
is APIGetChatItemTTL -> "/ttl"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
@@ -1560,10 +1596,10 @@ sealed class CC {
|
||||
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
|
||||
is ListContacts -> "/contacts"
|
||||
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
|
||||
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
|
||||
is ApiParseMarkdown -> "/_parse $text"
|
||||
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
|
||||
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
|
||||
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
|
||||
is CreateMyAddress -> "/address"
|
||||
is DeleteMyAddress -> "/delete_address"
|
||||
is ShowMyAddress -> "/show_address"
|
||||
@@ -1612,6 +1648,7 @@ sealed class CC {
|
||||
is APIGetGroupLink -> "apiGetGroupLink"
|
||||
is GetUserSMPServers -> "getUserSMPServers"
|
||||
is SetUserSMPServers -> "setUserSMPServers"
|
||||
is TestSMPServer -> "testSMPServer"
|
||||
is APISetChatItemTTL -> "apiSetChatItemTTL"
|
||||
is APIGetChatItemTTL -> "apiGetChatItemTTL"
|
||||
is APISetNetworkConfig -> "/apiSetNetworkConfig"
|
||||
@@ -1627,10 +1664,10 @@ sealed class CC {
|
||||
is ApiClearChat -> "apiClearChat"
|
||||
is ListContacts -> "listContacts"
|
||||
is ApiUpdateProfile -> "updateProfile"
|
||||
is ApiSetContactPrefs -> "apiSetContactPrefs"
|
||||
is ApiParseMarkdown -> "apiParseMarkdown"
|
||||
is ApiSetContactAlias -> "apiSetContactAlias"
|
||||
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
|
||||
is ApiSetContactPrefs -> "apiSetContactPrefs"
|
||||
is CreateMyAddress -> "createMyAddress"
|
||||
is DeleteMyAddress -> "deleteMyAddress"
|
||||
is ShowMyAddress -> "showMyAddress"
|
||||
@@ -1669,7 +1706,7 @@ sealed class CC {
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
|
||||
|
||||
fun smpServersStr(smpServers: List<String>) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ";")
|
||||
fun smpServersStr(smpServers: List<ServerCfg>) = if (smpServers.isEmpty()) "default" else json.encodeToString(SMPServersConfig(smpServers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,6 +1737,147 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? =
|
||||
@Serializable
|
||||
class DBEncryptionConfig(val currentKey: String, val newKey: String)
|
||||
|
||||
@Serializable
|
||||
data class SMPServersConfig(
|
||||
val smpServers: List<ServerCfg>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerCfg(
|
||||
val server: String,
|
||||
val preset: Boolean,
|
||||
val tested: Boolean? = null,
|
||||
val enabled: Boolean
|
||||
) {
|
||||
@Transient
|
||||
private val createdAt: Date = Date()
|
||||
// val sendEnabled: Boolean // can we potentially want to prevent sending on the servers we use to receive?
|
||||
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||
// In any case, "trusted/known" servers are out of scope of this change
|
||||
val id: String
|
||||
get() = "$server $createdAt"
|
||||
|
||||
val isBlank: Boolean
|
||||
get() = server.isBlank()
|
||||
|
||||
companion object {
|
||||
val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true)
|
||||
|
||||
class SampleData(
|
||||
val preset: ServerCfg,
|
||||
val custom: ServerCfg,
|
||||
val untested: ServerCfg
|
||||
)
|
||||
|
||||
val sampleData = SampleData(
|
||||
preset = ServerCfg(
|
||||
server = "smp://abcd@smp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true
|
||||
),
|
||||
custom = ServerCfg(
|
||||
server = "smp://abcd@smp9.simplex.im",
|
||||
preset = false,
|
||||
tested = false,
|
||||
enabled = false
|
||||
),
|
||||
untested = ServerCfg(
|
||||
server = "smp://abcd@smp10.simplex.im",
|
||||
preset = false,
|
||||
tested = null,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SMPTestStep {
|
||||
@SerialName("connect") Connect,
|
||||
@SerialName("createQueue") CreateQueue,
|
||||
@SerialName("secureQueue") SecureQueue,
|
||||
@SerialName("deleteQueue") DeleteQueue,
|
||||
@SerialName("disconnect") Disconnect;
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Connect -> generalGetString(R.string.smp_server_test_connect)
|
||||
CreateQueue -> generalGetString(R.string.smp_server_test_create_queue)
|
||||
SecureQueue -> generalGetString(R.string.smp_server_test_secure_queue)
|
||||
DeleteQueue -> generalGetString(R.string.smp_server_test_delete_queue)
|
||||
Disconnect -> generalGetString(R.string.smp_server_test_disconnect)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SMPTestFailure(
|
||||
val testStep: SMPTestStep,
|
||||
val testError: AgentErrorType
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is SMPTestFailure) return false
|
||||
return other.testStep == this.testStep
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return testStep.hashCode()
|
||||
}
|
||||
|
||||
val localizedDescription: String get() {
|
||||
val err = String.format(generalGetString(R.string.error_smp_test_failed_at_step), testStep.text)
|
||||
return when {
|
||||
testError is AgentErrorType.SMP && testError.smpErr is SMPErrorType.AUTH ->
|
||||
err + " " + generalGetString(R.string.error_smp_test_server_auth)
|
||||
testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK ->
|
||||
err + " " + generalGetString(R.string.error_smp_test_certificate)
|
||||
else -> err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerAddress(
|
||||
val hostnames: List<String>,
|
||||
val port: String,
|
||||
val keyHash: String,
|
||||
val basicAuth: String = ""
|
||||
) {
|
||||
val uri: String
|
||||
get() =
|
||||
"smp://${keyHash}${if (basicAuth.isEmpty()) "" else ":$basicAuth"}@${hostnames.joinToString(",")}"
|
||||
|
||||
val valid: Boolean
|
||||
get() = hostnames.isNotEmpty() && hostnames.toSet().size == hostnames.size
|
||||
|
||||
companion object {
|
||||
val empty = ServerAddress(
|
||||
hostnames = emptyList(),
|
||||
port = "",
|
||||
keyHash = "",
|
||||
basicAuth = ""
|
||||
)
|
||||
val sampleData = ServerAddress(
|
||||
hostnames = listOf("smp.simplex.im", "1234.onion"),
|
||||
port = "",
|
||||
keyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
|
||||
basicAuth = "server_password"
|
||||
)
|
||||
|
||||
fun parseServerAddress(s: String): ServerAddress? {
|
||||
val parsed = chatParseServer(s)
|
||||
return runCatching { json.decodeFromString(ParsedServerAddress.serializer(), parsed) }
|
||||
.onFailure { Log.d(TAG, "parseServerAddress decode error: $it") }
|
||||
.getOrNull()?.serverAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ParsedServerAddress (
|
||||
var serverAddress: ServerAddress?,
|
||||
var parseError: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetCfg(
|
||||
val socksProxy: String? = null,
|
||||
@@ -1717,8 +1895,8 @@ data class NetCfg(
|
||||
val defaults: NetCfg =
|
||||
NetCfg(
|
||||
socksProxy = null,
|
||||
tcpConnectTimeout = 7_500_000,
|
||||
tcpTimeout = 5_000_000,
|
||||
tcpConnectTimeout = 10_000_000,
|
||||
tcpTimeout = 7_000_000,
|
||||
tcpKeepAlive = KeepAliveOpts.defaults,
|
||||
smpPingInterval = 600_000_000
|
||||
)
|
||||
@@ -1726,8 +1904,8 @@ data class NetCfg(
|
||||
val proxyDefaults: NetCfg =
|
||||
NetCfg(
|
||||
socksProxy = ":9050",
|
||||
tcpConnectTimeout = 15_000_000,
|
||||
tcpTimeout = 10_000_000,
|
||||
tcpConnectTimeout = 20_000_000,
|
||||
tcpTimeout = 15_000_000,
|
||||
tcpKeepAlive = KeepAliveOpts.defaults,
|
||||
smpPingInterval = 600_000_000
|
||||
)
|
||||
@@ -1779,29 +1957,294 @@ data class ChatSettings(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatPreferences(
|
||||
val voice: ChatPreference? = null
|
||||
data class FullChatPreferences(
|
||||
val fullDelete: ChatPreference,
|
||||
val voice: ChatPreference,
|
||||
) {
|
||||
|
||||
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
|
||||
|
||||
companion object {
|
||||
val default = ChatPreferences(
|
||||
voice = ChatPreference(allow = PrefAllowed.NO)
|
||||
)
|
||||
val empty = ChatPreferences(
|
||||
voice = null
|
||||
)
|
||||
val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatPreferences(
|
||||
val fullDelete: ChatPreference? = null,
|
||||
val voice: ChatPreference? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatPreference(
|
||||
val allow: PrefAllowed
|
||||
val allow: FeatureAllowed
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class PrefAllowed {
|
||||
@SerialName("always") ALWAYS,
|
||||
data class ContactUserPreferences(
|
||||
val fullDelete: ContactUserPreference,
|
||||
val voice: ContactUserPreference,
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = ContactUserPreferences(
|
||||
fullDelete = ContactUserPreference(
|
||||
enabled = FeatureEnabled(forUser = false, forContact = false),
|
||||
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)),
|
||||
contactPreference = ChatPreference(allow = FeatureAllowed.NO)
|
||||
),
|
||||
voice = ContactUserPreference(
|
||||
enabled = FeatureEnabled(forUser = true, forContact = true),
|
||||
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)),
|
||||
contactPreference = ChatPreference(allow = FeatureAllowed.YES)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ContactUserPreference(
|
||||
val enabled: FeatureEnabled,
|
||||
val userPreference: ContactUserPref,
|
||||
val contactPreference: ChatPreference,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FeatureEnabled(
|
||||
val forUser: Boolean,
|
||||
val forContact: Boolean
|
||||
) {
|
||||
val text: String
|
||||
get() = when {
|
||||
forUser && forContact -> generalGetString(R.string.feature_enabled)
|
||||
forUser -> generalGetString(R.string.feature_enabled_for_you)
|
||||
forContact -> generalGetString(R.string.feature_enabled_for_contact)
|
||||
else -> generalGetString(R.string.feature_off)
|
||||
}
|
||||
|
||||
val iconColor: Color
|
||||
get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
|
||||
|
||||
companion object {
|
||||
fun enabled(user: ChatPreference, contact: ChatPreference): FeatureEnabled =
|
||||
when {
|
||||
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = true)
|
||||
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = true, forContact = false)
|
||||
contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
|
||||
user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
|
||||
else -> FeatureEnabled(forUser = true, forContact = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ContactUserPref {
|
||||
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() // contact override is set
|
||||
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() // global user default is used
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Feature {
|
||||
@SerialName("fullDelete") FullDelete,
|
||||
@SerialName("voice") Voice;
|
||||
|
||||
fun text() =
|
||||
when(this) {
|
||||
FullDelete -> generalGetString(R.string.full_deletion)
|
||||
Voice -> generalGetString(R.string.voice_messages)
|
||||
}
|
||||
|
||||
fun icon(filled: Boolean) =
|
||||
when(this) {
|
||||
FullDelete -> if (filled) Icons.Filled.DeleteForever else Icons.Outlined.DeleteForever
|
||||
Voice -> if (filled) Icons.Filled.KeyboardVoice else Icons.Outlined.KeyboardVoice
|
||||
}
|
||||
|
||||
fun allowDescription(allowed: FeatureAllowed): String =
|
||||
when (this) {
|
||||
FullDelete -> when (allowed) {
|
||||
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
|
||||
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
|
||||
FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
|
||||
}
|
||||
Voice -> when (allowed) {
|
||||
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
|
||||
FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
|
||||
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
|
||||
}
|
||||
}
|
||||
|
||||
fun enabledDescription(enabled: FeatureEnabled): String =
|
||||
when (this) {
|
||||
FullDelete -> when {
|
||||
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete)
|
||||
enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages)
|
||||
enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
|
||||
else -> generalGetString(R.string.message_deletion_prohibited)
|
||||
}
|
||||
Voice -> when {
|
||||
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
|
||||
enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
|
||||
enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_voice)
|
||||
else -> generalGetString(R.string.voice_prohibited_in_this_chat)
|
||||
}
|
||||
}
|
||||
|
||||
fun enableGroupPrefDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
|
||||
if (canEdit) {
|
||||
when(this) {
|
||||
FullDelete -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
|
||||
}
|
||||
Voice -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when(this) {
|
||||
FullDelete -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
|
||||
}
|
||||
Voice -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ContactFeatureAllowed {
|
||||
@Serializable @SerialName("userDefault") data class UserDefault(val default: FeatureAllowed): ContactFeatureAllowed()
|
||||
@Serializable @SerialName("always") object Always: ContactFeatureAllowed()
|
||||
@Serializable @SerialName("yes") object Yes: ContactFeatureAllowed()
|
||||
@Serializable @SerialName("no") object No: ContactFeatureAllowed()
|
||||
|
||||
companion object {
|
||||
fun values(def: FeatureAllowed): List<ContactFeatureAllowed> = listOf(UserDefault(def), Always, Yes, No)
|
||||
}
|
||||
|
||||
val allowed: FeatureAllowed
|
||||
get() = when (this) {
|
||||
is UserDefault -> this.default
|
||||
is Always -> FeatureAllowed.ALWAYS
|
||||
is Yes -> FeatureAllowed.YES
|
||||
is No -> FeatureAllowed.NO
|
||||
}
|
||||
val text: String
|
||||
get() = when (this) {
|
||||
is UserDefault -> String.format(generalGetString(R.string.chat_preferences_default), default.text)
|
||||
is Always -> generalGetString(R.string.chat_preferences_always)
|
||||
is Yes -> generalGetString(R.string.chat_preferences_yes)
|
||||
is No -> generalGetString(R.string.chat_preferences_no)
|
||||
}
|
||||
}
|
||||
|
||||
data class ContactFeaturesAllowed(
|
||||
val fullDelete: ContactFeatureAllowed,
|
||||
val voice: ContactFeatureAllowed
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = ContactFeaturesAllowed(
|
||||
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
|
||||
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed =
|
||||
ContactFeaturesAllowed(
|
||||
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
|
||||
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
|
||||
)
|
||||
|
||||
fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
|
||||
when (val pref = contactUserPreference.userPreference) {
|
||||
is ContactUserPref.User -> ContactFeatureAllowed.UserDefault(pref.preference.allow)
|
||||
is ContactUserPref.Contact -> when (pref.preference.allow) {
|
||||
FeatureAllowed.ALWAYS -> ContactFeatureAllowed.Always
|
||||
FeatureAllowed.YES -> ContactFeatureAllowed.Yes
|
||||
FeatureAllowed.NO -> ContactFeatureAllowed.No
|
||||
}
|
||||
}
|
||||
|
||||
fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
|
||||
ChatPreferences(
|
||||
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
|
||||
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
|
||||
)
|
||||
|
||||
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? =
|
||||
when(contactFeatureAllowed) {
|
||||
is ContactFeatureAllowed.UserDefault -> null
|
||||
is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS)
|
||||
is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES)
|
||||
is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class FeatureAllowed {
|
||||
@SerialName("yes") YES,
|
||||
@SerialName("no") NO
|
||||
@SerialName("no") NO,
|
||||
@SerialName("always") ALWAYS;
|
||||
|
||||
val text: String
|
||||
get() = when(this) {
|
||||
ALWAYS -> generalGetString(R.string.chat_preferences_always)
|
||||
YES -> generalGetString(R.string.chat_preferences_yes)
|
||||
NO -> generalGetString(R.string.chat_preferences_no)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class FullGroupPreferences(
|
||||
val fullDelete: GroupPreference,
|
||||
val voice: GroupPreference
|
||||
) {
|
||||
fun toGroupPreferences(): GroupPreferences =
|
||||
GroupPreferences(fullDelete = fullDelete, voice = voice)
|
||||
|
||||
companion object {
|
||||
val sampleData = FullGroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GroupPreferences(
|
||||
val fullDelete: GroupPreference?,
|
||||
val voice: GroupPreference?
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = GroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GroupPreference(
|
||||
val enable: GroupFeatureEnabled
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class GroupFeatureEnabled {
|
||||
@SerialName("on") ON,
|
||||
@SerialName("off") OFF;
|
||||
|
||||
val text: String
|
||||
get() = when (this) {
|
||||
ON -> generalGetString(R.string.chat_preferences_on)
|
||||
OFF -> generalGetString(R.string.chat_preferences_off)
|
||||
}
|
||||
|
||||
val iconColor: Color
|
||||
get() = if (this == ON) SimplexGreen else HighOrLowlight
|
||||
|
||||
}
|
||||
|
||||
val json = Json {
|
||||
@@ -1841,7 +2284,8 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
|
||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<ServerCfg>, val presetSMPServers: List<String>): CR()
|
||||
@Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): CR()
|
||||
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR()
|
||||
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
|
||||
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
|
||||
@@ -1856,7 +2300,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
|
||||
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val toContact: Contact): CR()
|
||||
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
|
||||
@Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR()
|
||||
@Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR()
|
||||
@@ -1939,6 +2383,7 @@ sealed class CR {
|
||||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is UserSMPServers -> "userSMPServers"
|
||||
is SmpTestResult -> "smpTestResult"
|
||||
is ChatItemTTL -> "chatItemTTL"
|
||||
is NetworkConfig -> "networkConfig"
|
||||
is ContactInfo -> "contactInfo"
|
||||
@@ -2033,7 +2478,8 @@ sealed class CR {
|
||||
is ChatStopped -> noDetails()
|
||||
is ApiChats -> json.encodeToString(chats)
|
||||
is ApiChat -> json.encodeToString(chat)
|
||||
is UserSMPServers -> json.encodeToString(smpServers)
|
||||
is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}"
|
||||
is SmpTestResult -> json.encodeToString(smpTestFailure)
|
||||
is ChatItemTTL -> json.encodeToString(chatItemTTL)
|
||||
is NetworkConfig -> json.encodeToString(networkConfig)
|
||||
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
|
||||
@@ -2048,7 +2494,7 @@ sealed class CR {
|
||||
is UserProfileUpdated -> json.encodeToString(toProfile)
|
||||
is ContactAliasUpdated -> json.encodeToString(toContact)
|
||||
is ConnectionAliasUpdated -> json.encodeToString(toConnection)
|
||||
is ContactPrefsUpdated -> json.encodeToString(toContact)
|
||||
is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}"
|
||||
is ParsedMarkdown -> json.encodeToString(formattedText)
|
||||
is UserContactLink -> contactLink.responseDetails
|
||||
is UserContactLinkUpdated -> contactLink.responseDetails
|
||||
@@ -2191,10 +2637,12 @@ sealed class ChatErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is NoActiveUser -> "noActiveUser"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is FileAlreadyReceiving -> "fileAlreadyReceiving"
|
||||
is СommandError -> "commandError $message"
|
||||
}
|
||||
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
|
||||
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
|
||||
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
@@ -136,7 +135,7 @@ fun TerminalLayout(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
||||
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
@@ -164,7 +163,8 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
|
||||
Text(
|
||||
"${item.date.toString().subSequence(11, 19)} ${item.label}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -173,7 +173,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
.clickable {
|
||||
ModalManager.shared.showModal {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details)
|
||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
}.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
||||
@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
|
||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
|
||||
@Composable
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
withApi {
|
||||
scope.launch {
|
||||
delay(2000L)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = wv
|
||||
|
||||
@@ -35,7 +35,7 @@ import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@@ -47,7 +47,6 @@ fun ChatInfoView(
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
close: () -> Unit,
|
||||
onChatUpdated: (Chat) -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
@@ -61,7 +60,12 @@ fun ChatInfoView(
|
||||
localAlias,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated)
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact.contactId)
|
||||
}
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
@@ -118,6 +122,7 @@ fun ChatInfoLayout(
|
||||
localAlias: String,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
@@ -144,13 +149,16 @@ fun ChatInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (developerTools) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionDivider()
|
||||
}
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -328,6 +336,15 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.contact_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
@@ -350,10 +367,9 @@ fun DeleteContactButton(onClick: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
|
||||
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
|
||||
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
|
||||
chatModel.updateContact(it)
|
||||
onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +404,7 @@ fun PreviewChatInfoLayout() {
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
|
||||
@@ -50,8 +50,8 @@ import java.io.File
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
||||
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
|
||||
val searchText = rememberSaveable { mutableStateOf("") }
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
@@ -61,41 +61,48 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
||||
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (activeChat.value?.id != chatModel.chatId.value) {
|
||||
activeChat.value = if (chatModel.chatId.value == null) {
|
||||
null
|
||||
} else {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (activeChat.value?.id != chatModel.chatId.value && chatModel.chatId.value != null) {
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
// Also for situation when chatId changes after clicking in notification, etc
|
||||
chatModel.getChat(chatModel.chatId.value!!)
|
||||
activeChat.value = chatModel.getChat(chatModel.chatId.value!!)
|
||||
}
|
||||
markUnreadChatAsRead(activeChat, chatModel)
|
||||
}
|
||||
markUnreadChatAsRead(activeChat, chatModel)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// .toList() is important for making observation working
|
||||
snapshotFlow { chatModel.chats.toList() }
|
||||
.distinctUntilChanged()
|
||||
.collect { chats ->
|
||||
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
|
||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||
if (it?.chatInfo != activeChat.value?.chatInfo) {
|
||||
activeChat.value = it
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (activeChat.value == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val chat = activeChat.value!!
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// We need to have real unreadCount value for displaying it inside top right button
|
||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||
val unreadCount = remember {
|
||||
derivedStateOf {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
ChatLayout(
|
||||
user,
|
||||
chat,
|
||||
unreadCount,
|
||||
composeState,
|
||||
@@ -108,25 +115,30 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
attachmentOption,
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
linkMode = chatModel.simplexLinkMode.value,
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = { chatModel.chatId.value = null },
|
||||
back = {
|
||||
hideKeyboard(view)
|
||||
AudioPlayer.stop()
|
||||
chatModel.chatId.value = null
|
||||
},
|
||||
info = {
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
|
||||
activeChat.value = it
|
||||
val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
contact.value?.let { ct ->
|
||||
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupChatInfoView(chatModel, close)
|
||||
}
|
||||
@@ -134,6 +146,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
@@ -177,6 +190,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
acceptCall = { contact ->
|
||||
hideKeyboard(view)
|
||||
val invitation = chatModel.callInvitations.remove(contact.id)
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||
@@ -185,6 +199,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
@@ -211,24 +226,24 @@ fun ChatView(chatModel: ChatModel) {
|
||||
apiFindMessages(c.chatInfo, chatModel, value)
|
||||
searchText.value = value
|
||||
}
|
||||
}
|
||||
},
|
||||
onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeView: (@Composable () -> Unit),
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
chatModelIncognito: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
@@ -243,8 +258,10 @@ fun ChatLayout(
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
onComposed: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
@@ -275,9 +292,9 @@ fun ChatLayout(
|
||||
) { contentPadding ->
|
||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||
ChatItemsList(
|
||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
||||
chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -428,13 +445,13 @@ val CIListStateSaver = run {
|
||||
|
||||
@Composable
|
||||
fun BoxWithConstraintsScope.ChatItemsList(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
chatModelIncognito: Boolean,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
@@ -444,11 +461,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
acceptCall: (Contact) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
onComposed: () -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
ScrollToBottom(chat.id, listState)
|
||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
@@ -476,6 +492,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
var stopListening = false
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||
.distinctUntilChanged()
|
||||
.filter { !stopListening }
|
||||
.collect {
|
||||
onComposed()
|
||||
stopListening = true
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
@@ -538,11 +564,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -553,7 +579,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +726,7 @@ fun PreloadItems(
|
||||
.map {
|
||||
val totalItemsNumber = it.totalItemsCount
|
||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||
totalItemsNumber
|
||||
else
|
||||
0
|
||||
@@ -914,7 +940,6 @@ fun PreviewChatLayout() {
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = chatItems,
|
||||
@@ -924,11 +949,11 @@ fun PreviewChatLayout() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -943,6 +968,7 @@ fun PreviewChatLayout() {
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -972,7 +998,6 @@ fun PreviewGroupChatLayout() {
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Group.sampleData,
|
||||
chatItems = chatItems,
|
||||
@@ -982,11 +1007,11 @@ fun PreviewGroupChatLayout() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -1001,6 +1026,7 @@ fun PreviewGroupChatLayout() {
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeVoiceView
|
||||
import ComposeFileView
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
@@ -14,30 +15,29 @@ import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -51,6 +51,7 @@ sealed class ComposePreview {
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ data class ComposeState(
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
|
||||
editingItem.content.text,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
@@ -86,6 +87,7 @@ data class ComposeState(
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
}
|
||||
@@ -95,6 +97,7 @@ data class ComposeState(
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
@@ -120,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
else -> ComposePreview.NoPreview
|
||||
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,53 +141,27 @@ fun ComposeView(
|
||||
showChooseAttachment: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val linkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = remember { mutableSetOf<String>() }
|
||||
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenContent = remember { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoUri = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoTmpFile = remember { mutableStateOf<File?>(null) }
|
||||
|
||||
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
|
||||
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(
|
||||
context: Context,
|
||||
input: Void?
|
||||
): SynchronousResult<Bitmap?>? = null
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||
val photoUriVal = photoUri.value
|
||||
val photoTmpFileVal = photoTmpFile.value
|
||||
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
photoTmpFileVal.delete()
|
||||
bitmap
|
||||
} else {
|
||||
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||
photoTmpFile.value?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
|
||||
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||
)
|
||||
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
chosenContent.value = listOf(UploadContent.SimpleImage(bitmap))
|
||||
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
|
||||
}
|
||||
}
|
||||
@@ -199,8 +177,17 @@ fun ComposeView(
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val drawable = ImageDecoder.decodeDrawable(source)
|
||||
var bitmap: Bitmap? = ImageDecoder.decodeBitmap(source)
|
||||
val drawable = try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: DecodeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
||||
if (drawable is AnimatedImageDrawable) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(context, uri)
|
||||
@@ -214,7 +201,7 @@ fun ComposeView(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (bitmap != null) content.add(UploadContent.SimpleImage(bitmap))
|
||||
content.add(UploadContent.SimpleImage(uri))
|
||||
}
|
||||
if (bitmap != null) {
|
||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||
@@ -243,7 +230,7 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
|
||||
@@ -345,6 +332,7 @@ fun ComposeView(
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
@@ -354,6 +342,7 @@ fun ComposeView(
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
@@ -391,7 +380,7 @@ fun ComposeView(
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.bitmap)
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
@@ -400,6 +389,15 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
@@ -450,6 +448,36 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||
val file = File(filePath)
|
||||
chosenAudio.value = file.toUri() to durationMs
|
||||
chatModel.filesToDelete.add(file)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||
}
|
||||
|
||||
fun allowVoiceToContact() {
|
||||
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||
val featuresAllowed = contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed).copy(voice = ChatPreference(FeatureAllowed.YES))
|
||||
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun showDisabledVoiceAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (chat.chatInfo is ChatInfo.Direct)
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
@@ -464,6 +492,11 @@ fun ComposeView(
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
@@ -479,6 +512,13 @@ fun ComposeView(
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||
preview.voice,
|
||||
preview.durationMs,
|
||||
preview.finished,
|
||||
cancelEnabled = !composeState.value.editing,
|
||||
::cancelVoice
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
preview.fileName,
|
||||
::cancelFile,
|
||||
@@ -513,44 +553,70 @@ fun ComposeView(
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
val attachEnabled = !composeState.value.editing
|
||||
Box(Modifier.padding(bottom = 12.dp)) {
|
||||
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (attachEnabled) {
|
||||
showChooseAttachment()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> chat.chatInfo.contact.mergedPreferences.voice.enabled.forUser
|
||||
is ChatInfo.Group -> chat.chatInfo.groupInfo.fullGroupPreferences.voice.enable == GroupFeatureEnabled.ON
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
allowedVoiceByPrefs = allowedVoiceByPrefs,
|
||||
needToAllowVoiceToContact = needToAllowVoiceToContact,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
::onAudioAdded,
|
||||
::allowVoiceToContact,
|
||||
::showDisabledVoiceAlert,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PickFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
type = "image/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||
}
|
||||
|
||||
class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ComposeVoiceView(
|
||||
filePath: String,
|
||||
recordedDurationMs: Int,
|
||||
finishedRecording: Boolean,
|
||||
cancelEnabled: Boolean,
|
||||
cancelVoice: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||
val progress = rememberSaveable { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||
val progressBarWidth = remember { Animatable(0f) }
|
||||
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||
snapshotFlow { progress.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
val startTime = when {
|
||||
audioPlaying.value -> progress.value
|
||||
finishedRecording && progress.value == duration.value -> progress.value
|
||||
finishedRecording -> 0
|
||||
else -> recordedDurationMs
|
||||
}
|
||||
val endTime = when {
|
||||
finishedRecording -> duration.value
|
||||
audioPlaying.value -> recordedDurationMs
|
||||
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
|
||||
}
|
||||
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
Modifier
|
||||
.requiredWidth(progressBarWidth.value.dp)
|
||||
.padding(top = 58.dp)
|
||||
.height(3.dp)
|
||||
.background(MaterialTheme.colors.primary)
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!audioPlaying.value) {
|
||||
AudioPlayer.play(filePath, audioPlaying, progress, duration)
|
||||
} else {
|
||||
AudioPlayer.pause(audioPlaying, progress)
|
||||
}
|
||||
},
|
||||
enabled = finishedRecording) {
|
||||
Icon(
|
||||
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
|
||||
)
|
||||
}
|
||||
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||
derivedStateOf { if (audioPlaying.value) progress.value / 1000 else recordedDurationMs / 1000 }
|
||||
}
|
||||
Text(
|
||||
durationToString(numberInText.value),
|
||||
fontSize = 18.sp,
|
||||
color = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewComposeAudioView() {
|
||||
SimpleXTheme {
|
||||
ComposeFileView(
|
||||
"test.txt",
|
||||
cancelFile = {},
|
||||
cancelEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ContactPreferencesView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
contactId: Long,
|
||||
) {
|
||||
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
val ct = contact.value ?: return
|
||||
var featuresAllowed by remember(ct) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||
var currentFeaturesAllowed by remember(ct) { mutableStateOf(featuresAllowed) }
|
||||
ContactPreferencesLayout(
|
||||
featuresAllowed,
|
||||
currentFeaturesAllowed,
|
||||
user,
|
||||
ct,
|
||||
applyPrefs = { prefs ->
|
||||
featuresAllowed = prefs
|
||||
},
|
||||
reset = {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
m.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesLayout(
|
||||
featuresAllowed: ContactFeaturesAllowed,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed,
|
||||
user: User,
|
||||
contact: Contact,
|
||||
applyPrefs: (ContactFeaturesAllowed) -> Unit,
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||
// val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||
// FeatureSection(Feature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||
// applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||
// }
|
||||
// SectionSpacer()
|
||||
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||
FeatureSection(Feature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||
applyPrefs(featuresAllowed.copy(voice = it))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = featuresAllowed == currentFeaturesAllowed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(
|
||||
feature: Feature,
|
||||
userDefault: FeatureAllowed,
|
||||
pref: ContactUserPreference,
|
||||
allowFeature: State<ContactFeatureAllowed>,
|
||||
onSelected: (ContactFeatureAllowed) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
user = ChatPreference(allow = allowFeature.value.allowed),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
SectionView(
|
||||
feature.text().uppercase(),
|
||||
icon = feature.icon(true),
|
||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||
leadingIcon = true,
|
||||
) {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||
allowFeature,
|
||||
icon = null,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
InfoRow(
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
}
|
||||
SectionTextFooter(feature.enabledDescription(enabled))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIDirection
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
@@ -53,6 +52,7 @@ fun ContextItemView(
|
||||
MarkdownText(
|
||||
contextItem.text, contextItem.formattedText,
|
||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
@@ -12,38 +15,226 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.core.widget.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.SharedContent
|
||||
import kotlinx.coroutines.delay
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
showVoiceRecordIcon: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
showDisabledVoiceAlert: () -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
val cs = composeState.value
|
||||
val attachEnabled = !composeState.value.editing
|
||||
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||
Box(if (recordingTimeRange.first == 0L)
|
||||
Modifier
|
||||
else
|
||||
Modifier.clickable(false, onClick = {})
|
||||
) {
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
} else if (!showVoiceButton) {
|
||||
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
)
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
now = System.currentTimeMillis()
|
||||
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
rec.stop()
|
||||
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||
}
|
||||
val startStopRecording: () -> Unit = {
|
||||
when {
|
||||
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||
needToAllowVoiceToContact -> {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.allow_voice_messages_question),
|
||||
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(R.string.allow_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
onConfirm = allowVoiceToContact,
|
||||
)
|
||||
}
|
||||
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
|
||||
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||
filePath.value == null -> {
|
||||
recordingTimeRange = System.currentTimeMillis()..0L
|
||||
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(stopRecOnNextClick) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
if (stopRecOnNextClick) {
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
val cleanUp = { remove: Boolean ->
|
||||
rec.stop()
|
||||
AudioPlayer.stop(filePath.value)
|
||||
if (remove) filePath.value?.let { File(it).delete() }
|
||||
filePath.value = null
|
||||
stopRecOnNextClick = false
|
||||
recordingTimeRange = 0L..0L
|
||||
}
|
||||
LaunchedEffect(cs.preview) {
|
||||
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||
// Pressed on X icon in preview
|
||||
cleanUp(true)
|
||||
}
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
// It's just a key for triggering dropping a state in the compose function. Without it
|
||||
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
|
||||
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
|
||||
onPress = {
|
||||
if (filePath.value == null) startStopRecording()
|
||||
},
|
||||
onClick = {
|
||||
// Voice not allowed or not granted voice record permission for the app
|
||||
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
|
||||
if (!recordingInProgress.value && filePath.value != null) {
|
||||
sendMessage()
|
||||
cleanUp(false)
|
||||
} else if (stopRecOnNextClick) {
|
||||
stopRecordingAndAddAudio()
|
||||
stopRecOnNextClick = false
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick = true
|
||||
}
|
||||
},
|
||||
onCancel = startStopRecording,
|
||||
onRelease = startStopRecording
|
||||
)
|
||||
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||
Modifier.clip(CircleShape).background(color)
|
||||
else
|
||||
Modifier
|
||||
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||
Icon(
|
||||
when {
|
||||
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
|
||||
stopRecOnNextClick -> Icons.Filled.Stop
|
||||
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
|
||||
else -> Icons.Outlined.KeyboardVoice
|
||||
},
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = when {
|
||||
recordingTimeRange.last != 0L -> Color.White
|
||||
stopRecOnNextClick -> MaterialTheme.colors.primary
|
||||
allowedVoiceByPrefs -> MaterialTheme.colors.primary
|
||||
else -> HighOrLowlight
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.then(sendButtonModifier)
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
rec.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondary
|
||||
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
when (cs.contextItem) {
|
||||
@@ -58,99 +249,69 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
}
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondary
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: android.view.OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: android.view.OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress
|
||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.clickable {
|
||||
if (cs.sendEnabled()) {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||
it.isFocusableInTouchMode = it.isFocusable
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
Text(
|
||||
generalGetString(R.string.voice_message_send_text),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +328,14 @@ fun PreviewSendMsgView() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -188,8 +355,14 @@ fun PreviewSendMsgViewEditing() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -209,8 +382,14 @@ fun PreviewSendMsgViewInProgress() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -35,14 +34,16 @@ import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
||||
|
||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||
BackHandler(onBack = close)
|
||||
AddGroupMembersLayout(
|
||||
groupInfo = groupInfo,
|
||||
contactsToAdd = getContactsToAdd(chatModel),
|
||||
selectedContacts = selectedContacts,
|
||||
selectedRole = selectedRole,
|
||||
allowModifyMembers = allowModifyMembers,
|
||||
inviteMembers = {
|
||||
allowModifyMembers = false
|
||||
withApi {
|
||||
for (contactId in selectedContacts) {
|
||||
val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
|
||||
@@ -79,8 +80,9 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
||||
fun AddGroupMembersLayout(
|
||||
groupInfo: GroupInfo,
|
||||
contactsToAdd: List<Contact>,
|
||||
selectedContacts: SnapshotStateList<Long>,
|
||||
selectedContacts: List<Long>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
allowModifyMembers: Boolean,
|
||||
inviteMembers: () -> Unit,
|
||||
clearSelection: () -> Unit,
|
||||
addContact: (Long) -> Unit,
|
||||
@@ -119,18 +121,18 @@ fun AddGroupMembersLayout(
|
||||
} else {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
RoleSelectionRow(groupInfo, selectedRole)
|
||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||
}
|
||||
SectionDivider()
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
||||
}
|
||||
SectionCustomFooter {
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
@@ -138,7 +140,7 @@ fun AddGroupMembersLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -150,7 +152,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
enabled = rememberUpdatedState(enabled),
|
||||
onSelected = { selectedRole.value = it }
|
||||
)
|
||||
}
|
||||
@@ -169,7 +171,7 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
|
||||
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -182,11 +184,11 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Box(
|
||||
Modifier.clickable { clearSelection() }
|
||||
Modifier.clickable { if (enabled) clearSelection() }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.clear_contacts_selection_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@@ -203,8 +205,9 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
|
||||
@Composable
|
||||
fun ContactList(
|
||||
contacts: List<Contact>,
|
||||
selectedContacts: SnapshotStateList<Long>,
|
||||
selectedContacts: List<Long>,
|
||||
groupInfo: GroupInfo,
|
||||
enabled: Boolean,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit
|
||||
) {
|
||||
@@ -212,7 +215,8 @@ fun ContactList(
|
||||
contacts.forEachIndexed { index, contact ->
|
||||
ContactCheckRow(
|
||||
contact, groupInfo, addContact, removeContact,
|
||||
checked = selectedContacts.contains(contact.apiId)
|
||||
checked = selectedContacts.contains(contact.apiId),
|
||||
enabled = enabled,
|
||||
)
|
||||
if (index < contacts.lastIndex) {
|
||||
SectionDivider()
|
||||
@@ -227,7 +231,8 @@ fun ContactCheckRow(
|
||||
groupInfo: GroupInfo,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit,
|
||||
checked: Boolean
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||
val icon: ImageVector
|
||||
@@ -237,19 +242,23 @@ fun ContactCheckRow(
|
||||
iconColor = HighOrLowlight
|
||||
} else if (checked) {
|
||||
icon = Icons.Filled.CheckCircle
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
|
||||
} else {
|
||||
icon = Icons.Outlined.Circle
|
||||
iconColor = HighOrLowlight
|
||||
}
|
||||
SectionItemView(click = {
|
||||
if (prohibitedToInviteIncognito) {
|
||||
showProhibitedToInviteIncognitoAlertDialog()
|
||||
} else if (!checked)
|
||||
addContact(contact.apiId)
|
||||
else
|
||||
removeContact(contact.apiId)
|
||||
}) {
|
||||
SectionItemView(
|
||||
click = if (enabled) {
|
||||
{
|
||||
if (prohibitedToInviteIncognito) {
|
||||
showProhibitedToInviteIncognitoAlertDialog()
|
||||
} else if (!checked)
|
||||
addContact(contact.apiId)
|
||||
else
|
||||
removeContact(contact.apiId)
|
||||
}
|
||||
} else null
|
||||
) {
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||
Text(
|
||||
@@ -282,6 +291,7 @@ fun PreviewAddGroupMembersLayout() {
|
||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||
selectedContacts = remember { mutableStateListOf() },
|
||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||
allowModifyMembers = true,
|
||||
inviteMembers = {},
|
||||
clearSelection = {},
|
||||
addContact = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
@@ -28,6 +29,7 @@ import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
@@ -62,6 +64,14 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
editGroupProfile = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
GroupPreferencesView(
|
||||
chatModel,
|
||||
groupInfo
|
||||
)
|
||||
}
|
||||
},
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
@@ -120,6 +130,7 @@ fun GroupChatInfoLayout(
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteGroup: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
leaveGroup: () -> Unit,
|
||||
@@ -139,6 +150,16 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
}
|
||||
GroupPreferencesButton(openPreferences)
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
||||
@@ -160,10 +181,6 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
}
|
||||
ClearChatButton(clearChat)
|
||||
if (groupInfo.canDelete) {
|
||||
SectionDivider()
|
||||
@@ -211,6 +228,15 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.group_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
Row(
|
||||
@@ -286,10 +312,10 @@ fun GroupLinkButton() {
|
||||
Icon(
|
||||
Icons.Outlined.Link,
|
||||
stringResource(R.string.group_link),
|
||||
tint = MaterialTheme.colors.primary
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.group_link))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,10 +329,10 @@ fun EditGroupProfileButton() {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
stringResource(R.string.button_edit_group_profile),
|
||||
tint = MaterialTheme.colors.primary
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.button_edit_group_profile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +381,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,8 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (developerTools) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
}
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
val rcvServers = connStats.rcvServers
|
||||
val sndServers = connStats.sndServers
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) {
|
||||
var preferences by remember { mutableStateOf(groupInfo.fullGroupPreferences) }
|
||||
var currentPreferences by remember { mutableStateOf(preferences) }
|
||||
GroupPreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
groupInfo,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val gp = groupInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||
val gInfo = m.controller.apiUpdateGroup(groupInfo.groupId, gp)
|
||||
if (gInfo != null) {
|
||||
m.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupPreferencesLayout(
|
||||
preferences: FullGroupPreferences,
|
||||
currentPreferences: FullGroupPreferences,
|
||||
groupInfo: GroupInfo,
|
||||
applyPrefs: (FullGroupPreferences) -> Unit,
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_preferences))
|
||||
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||
// FeatureSection(Feature.FullDelete, allowFullDeletion, groupInfo) {
|
||||
// applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||
// }
|
||||
// SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||
FeatureSection(Feature.Voice, allowVoice, groupInfo) {
|
||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||
}
|
||||
if (groupInfo.canEdit) {
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = preferences == currentPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(feature: Feature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
feature.text(),
|
||||
GroupFeatureEnabled.values().map { it to it.text },
|
||||
enableFeature,
|
||||
icon = feature.icon(false),
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
} else {
|
||||
InfoRow(
|
||||
feature.text(),
|
||||
enableFeature.value.text
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.enableGroupPrefDescription(enableFeature.value, groupInfo.canEdit))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package chat.simplex.app.views.chat.group
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -53,8 +55,8 @@ fun GroupProfileLayout(
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val profileImage = remember { mutableStateOf(groupProfile.image) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.durationToString
|
||||
|
||||
@Composable
|
||||
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||
@@ -24,7 +25,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
|
||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||
when (status) {
|
||||
CICallStatus.Pending -> if (sent) {
|
||||
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
||||
@@ -38,7 +39,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||
CICallStatus.Ended -> Row {
|
||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(status.duration(duration), color = HighOrLowlight)
|
||||
Text(durationToString(duration), color = HighOrLowlight)
|
||||
}
|
||||
CICallStatus.Error -> {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CIChatFeatureView(
|
||||
chatItem: ChatItem,
|
||||
feature: Feature,
|
||||
iconColor: Color
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(feature.icon(true), feature.text(), Modifier.size(15.dp), tint = iconColor)
|
||||
Text(
|
||||
chatEventText(chatItem),
|
||||
Modifier,
|
||||
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,32 +18,35 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun CIEventView(ci: ChatItem) {
|
||||
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||
}
|
||||
|
||||
Surface {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
withGroupEventStyle(this, memberDisplayName)
|
||||
append(" ")
|
||||
}
|
||||
withGroupEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withGroupEventStyle(this, ci.timestampText)
|
||||
},
|
||||
chatEventText(ci),
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||
}
|
||||
|
||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
withGroupEventStyle(this, memberDisplayName)
|
||||
append(" ")
|
||||
}
|
||||
withGroupEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withGroupEventStyle(this, ci.timestampText)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,9 @@ fun CIImageView(
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
val view = LocalView.current
|
||||
imageView(imagePainter, onClick = {
|
||||
hideKeyboard(view)
|
||||
if (getLoadedFilePath(context, file) != null) {
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.WarningYellow
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -51,7 +52,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun CIVoiceView(
|
||||
providedDurationSec: Int,
|
||||
file: CIFile?,
|
||||
edited: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
ci: ChatItem,
|
||||
metaColor: Color,
|
||||
longClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (file != null) {
|
||||
val context = LocalContext.current
|
||||
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
|
||||
val play = {
|
||||
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
|
||||
brokenAudio = !audioPlaying.value
|
||||
}
|
||||
val pause = {
|
||||
AudioPlayer.pause(audioPlaying, progress)
|
||||
}
|
||||
|
||||
val time = if (audioPlaying.value) progress.value else duration.value
|
||||
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
|
||||
val text = durationToString(time / 1000)
|
||||
if (hasText) {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Text(
|
||||
text,
|
||||
Modifier
|
||||
.padding(start = 12.dp, end = 5.dp)
|
||||
.widthIn(min = minWidth),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Start,
|
||||
maxLines = 1
|
||||
)
|
||||
} else {
|
||||
if (sent) {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Text(
|
||||
text,
|
||||
Modifier
|
||||
.padding(end = 12.dp)
|
||||
.widthIn(min = minWidth),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
Column {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Column {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
Modifier
|
||||
.padding(start = 12.dp)
|
||||
.widthIn(min = minWidth),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||
val metaReserve = if (edited)
|
||||
" "
|
||||
else
|
||||
" "
|
||||
Text(metaReserve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayPauseButton(
|
||||
audioPlaying: Boolean,
|
||||
sent: Boolean,
|
||||
angle: Float,
|
||||
strokeWidth: Float,
|
||||
strokeColor: Color,
|
||||
enabled: Boolean,
|
||||
error: Boolean,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (!audioPlaying) play() else pause() },
|
||||
onLongClick = longClick
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceMsgIndicator(
|
||||
file: CIFile?,
|
||||
audioPlaying: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
progress: State<Int>?,
|
||||
duration: State<Int>?,
|
||||
error: Boolean,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
) {
|
||||
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
|
||||
val strokeColor = MaterialTheme.colors.primary
|
||||
if (file != null && file.loaded && progress != null && duration != null) {
|
||||
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
|
||||
if (hasText) {
|
||||
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||
Icon(
|
||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||
}
|
||||
} else {
|
||||
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
val brush = Brush.linearGradient(
|
||||
0f to Color.Transparent,
|
||||
0f to color,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(strokeWidth, strokeWidth),
|
||||
tileMode = TileMode.Clamp
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawArc(
|
||||
brush = brush,
|
||||
startAngle = -90f,
|
||||
sweepAngle = angle,
|
||||
useCenter = false,
|
||||
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
|
||||
size = Size(size.width - strokeWidth, size.height - strokeWidth),
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.*
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -29,16 +28,13 @@ import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
user: User,
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
chatModelIncognito: Boolean,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
@@ -46,6 +42,7 @@ fun ChatItemView(
|
||||
scrollToItem: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
@@ -56,17 +53,28 @@ fun ChatItemView(
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
val onClick = {
|
||||
when (cItem.meta.itemStatus) {
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||
) {
|
||||
@Composable fun ContentItem() {
|
||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
@@ -84,29 +92,30 @@ fun ChatItemView(
|
||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||
when {
|
||||
filePath != null -> shareFile(cxt, cItem.text, filePath)
|
||||
else -> shareText(cxt, cItem.content.text)
|
||||
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||
else -> shareText(context, cItem.content.text)
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(cxt, cItem.content.text)
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable) {
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
@@ -161,6 +170,11 @@ fun ChatItemView(
|
||||
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.feature, c.preference.enable.iconColor)
|
||||
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.feature, c.preference.enable.iconColor)
|
||||
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,20 +224,25 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.message_delivery_error_title),
|
||||
text = description,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
@@ -238,13 +257,11 @@ fun PreviewChatItemView() {
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -39,6 +39,7 @@ fun FramedItemView(
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
linkMode: SimplexLinkMode,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
@@ -58,7 +59,8 @@ fun FramedItemView(
|
||||
) {
|
||||
MarkdownText(
|
||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||
linkMode = linkMode
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -87,13 +89,13 @@ fun FramedItemView(
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile -> {
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
|
||||
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||
Modifier
|
||||
.padding(top = 6.dp, end = 4.dp)
|
||||
.size(22.dp),
|
||||
@@ -105,7 +107,16 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||
if (text != "") {
|
||||
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
@@ -139,26 +150,35 @@ fun FramedItemView(
|
||||
if (mc.text == "") {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||
is MsgContent.MCUnknown ->
|
||||
if (ci.file == null) {
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
} else {
|
||||
ciFileView(ci, mc.text)
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview)
|
||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,13 +188,14 @@ fun FramedItemView(
|
||||
fun CIMarkdownText(
|
||||
ci: ChatItem,
|
||||
showMember: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
MarkdownText(
|
||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||
)
|
||||
}
|
||||
@@ -225,6 +246,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -241,6 +263,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -261,6 +284,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -282,6 +306,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
|
||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -303,6 +328,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
|
||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -331,6 +357,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -359,6 +386,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -386,6 +414,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.*
|
||||
@@ -76,12 +77,14 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
val image = provider.getImage(index)
|
||||
if (image == null) {
|
||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||
scope.launch {
|
||||
when (settledCurrentPage) {
|
||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
SideEffect {
|
||||
scope.launch {
|
||||
when (settledCurrentPage) {
|
||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.text.BidiFormatter
|
||||
@@ -49,6 +50,7 @@ fun MarkdownText (
|
||||
uriHandler: UriHandler? = null,
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
linkMode: SimplexLinkMode,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
@@ -79,12 +81,16 @@ fun MarkdownText (
|
||||
for (ft in formattedText) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else {
|
||||
val link = ft.link
|
||||
val link = ft.link(linkMode)
|
||||
if (link != null) {
|
||||
hasLinks = true
|
||||
val ftStyle = ft.format.style
|
||||
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
|
||||
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
|
||||
} else {
|
||||
ft.format.style
|
||||
}
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
|
||||
@@ -33,6 +33,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||
}
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
val linkMode = chatModel.controller.appPrefs.simplexLinkMode.get()
|
||||
LaunchedEffect(chat.id) {
|
||||
showMenu.value = false
|
||||
delay(500L)
|
||||
@@ -40,7 +41,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -48,7 +49,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
)
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -536,13 +537,11 @@ fun ChatListNavLinkLayout(
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
Surface(modifier) {
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp),
|
||||
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
@@ -588,7 +587,8 @@ fun PreviewChatListNavLinkDirect() {
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
@@ -625,7 +625,8 @@ fun PreviewChatListNavLinkGroup() {
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
|
||||
@@ -26,7 +26,7 @@ import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
@@ -86,6 +86,7 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
ci.text,
|
||||
ci.formattedText,
|
||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||
linkMode = linkMode,
|
||||
senderBold = true,
|
||||
metaText = null,
|
||||
maxLines = 2,
|
||||
@@ -232,6 +233,6 @@ fun ChatStatusImage(chat: Chat) {
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ fun DatabaseLayout(
|
||||
chatDbChanged: Boolean,
|
||||
useKeyChain: Boolean,
|
||||
chatDbEncrypted: Boolean?,
|
||||
initialRandomDBPassphrase: Preference<Boolean>,
|
||||
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
@@ -409,7 +409,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
|
||||
m.controller.apiStopChat()
|
||||
runChat.value = false
|
||||
m.chatRunning.value = false
|
||||
SimplexService.stop(context)
|
||||
SimplexService.safeStopService(context)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
} catch (e: Error) {
|
||||
runChat.value = true
|
||||
@@ -683,7 +683,7 @@ fun PreviewDatabaseLayout() {
|
||||
chatDbChanged = false,
|
||||
useKeyChain = false,
|
||||
chatDbEncrypted = false,
|
||||
initialRandomDBPassphrase = Preference({ true }, {}),
|
||||
initialRandomDBPassphrase = SharedPreference({ true }, {}),
|
||||
importArchiveLauncher = rememberGetContentLauncher {},
|
||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
|
||||
class AlertManager {
|
||||
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
|
||||
var presentAlert = mutableStateOf<Boolean>(false)
|
||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||
|
||||
fun showAlert(alert: @Composable () -> Unit) {
|
||||
Log.d(TAG, "AlertManager.showAlert")
|
||||
alertView.value = alert
|
||||
presentAlert.value = true
|
||||
alertViews.add(alert)
|
||||
}
|
||||
|
||||
fun hideAlert() {
|
||||
presentAlert.value = false
|
||||
alertView.value = null
|
||||
alertViews.removeLastOrNull()
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
@@ -40,6 +41,26 @@ class AlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogButtonsColumn(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
showAlert {
|
||||
Dialog(onDismissRequest = this::hideAlert) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
|
||||
if (text != null) {
|
||||
Text(text)
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
@@ -101,7 +122,7 @@ class AlertManager {
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (presentAlert.value) alertView.value?.invoke()
|
||||
remember { alertViews }.lastOrNull()?.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
|
||||
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
|
||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||
|
||||
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||
|
||||
@@ -33,6 +33,6 @@ enum class NewChatSheetState {
|
||||
}
|
||||
|
||||
sealed class UploadContent {
|
||||
data class SimpleImage(val bitmap: Bitmap): UploadContent()
|
||||
data class SimpleImage(val uri: Uri): UploadContent()
|
||||
data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
@@ -27,7 +30,7 @@ fun <T> ExposedDropDownSettingRow(
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -40,9 +43,7 @@ fun <T> ExposedDropDownSettingRow(
|
||||
tint = iconTint
|
||||
)
|
||||
}
|
||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
@@ -55,8 +56,10 @@ fun <T> ExposedDropDownSettingRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
|
||||
Text(
|
||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||
Modifier.widthIn(max = maxWidth),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
|
||||
@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember(key) { MutableInteractionSource() }
|
||||
LaunchedEffect(interactionSource) {
|
||||
var firstTapTime = 0L
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> {
|
||||
firstTapTime = System.currentTimeMillis(); onPress()
|
||||
}
|
||||
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
|
||||
is PressInteraction.Cancel -> onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
suspend fun PointerInputScope.detectTransformGestures(
|
||||
allowIntercept: () -> Boolean,
|
||||
panZoomLock: Boolean = false,
|
||||
|
||||
@@ -2,8 +2,7 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
@@ -20,8 +19,9 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -31,7 +31,12 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.json
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.chat.PickFromGallery
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
import kotlinx.serialization.builtins.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
@@ -106,15 +111,12 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
private var uri: Uri? = null
|
||||
private var tmpFile: File? = null
|
||||
lateinit var externalContext: Context
|
||||
|
||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
externalContext = context
|
||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||
tmpFile?.deleteOnExit()
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
@@ -123,20 +125,28 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
override fun getSynchronousResult(
|
||||
context: Context,
|
||||
input: Void?
|
||||
): SynchronousResult<Bitmap?>? = null
|
||||
): SynchronousResult<Uri?>? = null
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return if (resultCode == Activity.RESULT_OK && uri != null) {
|
||||
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
tmpFile?.delete()
|
||||
bitmap
|
||||
uri
|
||||
} else {
|
||||
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||
tmpFile?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun saver(): Saver<CustomTakePicturePreview, *> = Saver(
|
||||
save = { json.encodeToString(ListSerializer(String.serializer().nullable), listOf(it.uri?.toString(), it.tmpFile?.toString())) },
|
||||
restore = {
|
||||
val data = json.decodeFromString(ListSerializer(String.serializer().nullable), it)
|
||||
val uri = if (data[0] != null) Uri.parse(data[0]) else null
|
||||
val tmpFile = if (data[1] != null) File(data[1]) else null
|
||||
CustomTakePicturePreview(uri, tmpFile)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
//class GetGalleryContent: ActivityResultContracts.GetContent() {
|
||||
// override fun createIntent(context: Context, input: String): Intent {
|
||||
@@ -148,8 +158,12 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
|
||||
@Composable
|
||||
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
|
||||
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
|
||||
fun rememberCameraLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<Void?, Uri?> {
|
||||
val contract = rememberSaveable(stateSaver = CustomTakePicturePreview.saver()) {
|
||||
mutableStateOf(CustomTakePicturePreview(null, null))
|
||||
}
|
||||
return rememberLauncherForActivityResult(contract = contract.value, cb)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
|
||||
@@ -165,22 +179,26 @@ fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivit
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Bitmap?>,
|
||||
imageBitmap: MutableState<Uri?>,
|
||||
onImageChange: (Bitmap) -> Unit,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
val processPickedImage = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
imageBitmap.value = bitmap
|
||||
imageBitmap.value = uri
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = bitmap
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it) }
|
||||
val galleryLauncherFallback = rememberGetContentLauncher { processPickedImage(it) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
imageBitmap.value = uri
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
@@ -219,7 +237,11 @@ fun GetImageBottomSheet(
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
galleryLauncher.launch("image/*")
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
}
|
||||
hideBottomSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,6 @@ fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
|
||||
context,
|
||||
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laFailedToast(context: Context) = Toast.makeText(
|
||||
context,
|
||||
generalGetString(R.string.auth_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.media.*
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
interface Recorder {
|
||||
val recordingInProgress: MutableState<Boolean>
|
||||
fun start(onStop: () -> Unit): String
|
||||
fun stop()
|
||||
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
|
||||
}
|
||||
|
||||
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
companion object {
|
||||
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||
var stopRecording: (() -> Unit)? = null
|
||||
}
|
||||
override val recordingInProgress = mutableStateOf(false)
|
||||
private var recorder: MediaRecorder? = null
|
||||
private fun initRecorder() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(SimplexApp.context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
override fun start(onStop: () -> Unit): String {
|
||||
AudioPlayer.stop()
|
||||
recordingInProgress.value = true
|
||||
val rec: MediaRecorder
|
||||
recorder = initRecorder().also { rec = it }
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
rec.setAudioChannels(1)
|
||||
rec.setAudioSamplingRate(16000)
|
||||
rec.setAudioEncodingBitRate(16000)
|
||||
rec.setMaxDuration(-1) // TODO set limit
|
||||
rec.setMaxFileSize(recordedBytesLimit)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||
rec.setOutputFile(filePath)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
rec.setOnInfoListener { mr, what, extra ->
|
||||
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
|
||||
stop()
|
||||
onStop()
|
||||
}
|
||||
}
|
||||
stopRecording = { stop(); onStop() }
|
||||
return filePath
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!recordingInProgress.value) return
|
||||
stopRecording = null
|
||||
recordingInProgress.value = false
|
||||
recorder?.metrics?.
|
||||
runCatching {
|
||||
recorder?.stop()
|
||||
}
|
||||
runCatching {
|
||||
recorder?.reset()
|
||||
}
|
||||
runCatching {
|
||||
// release all resources
|
||||
recorder?.release()
|
||||
}
|
||||
recorder = null
|
||||
}
|
||||
|
||||
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
|
||||
stop()
|
||||
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
|
||||
}
|
||||
}
|
||||
|
||||
object AudioPlayer {
|
||||
private val player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
// Filepath: String, onProgressUpdate
|
||||
// onProgressUpdate(null) means stop
|
||||
private val currentlyPlaying: MutableState<Pair<String, (position: Int?) -> Unit>?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
// Returns real duration of the track
|
||||
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?) -> Unit): Int? {
|
||||
if (!File(filePath).exists()) {
|
||||
Log.e(TAG, "No such file: $filePath")
|
||||
return null
|
||||
}
|
||||
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != filePath) {
|
||||
stopListener()
|
||||
player.reset()
|
||||
runCatching {
|
||||
player.setDataSource(filePath)
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
return null
|
||||
}
|
||||
runCatching { player.prepare() }.onFailure {
|
||||
// Can happen when audio file is broken
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (seek != null) player.seekTo(seek)
|
||||
player.start()
|
||||
currentlyPlaying.value = filePath to onProgressUpdate
|
||||
progressJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
onProgressUpdate(player.currentPosition)
|
||||
while(isActive && player.isPlaying) {
|
||||
// Even when current position is equal to duration, the player has isPlaying == true for some time,
|
||||
// so help to make the playback stopped in UI immediately
|
||||
if (player.currentPosition == player.duration) {
|
||||
onProgressUpdate(player.currentPosition)
|
||||
break
|
||||
}
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition)
|
||||
}
|
||||
/*
|
||||
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
|
||||
* the player can show position != duration even if they actually equal.
|
||||
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
|
||||
* */
|
||||
if (isActive) {
|
||||
onProgressUpdate(player.duration)
|
||||
}
|
||||
onProgressUpdate(null)
|
||||
}
|
||||
return player.duration
|
||||
}
|
||||
|
||||
private fun pause(): Int {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
player.pause()
|
||||
return player.currentPosition
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!player.isPlaying) return
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||
|
||||
// FileName or filePath are ok
|
||||
fun stop(fileName: String?) {
|
||||
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
// Notify prev audio listener about stop
|
||||
currentlyPlaying.value?.second?.invoke(null)
|
||||
currentlyPlaying.value = null
|
||||
}
|
||||
|
||||
fun play(
|
||||
filePath: String?,
|
||||
audioPlaying: MutableState<Boolean>,
|
||||
progress: MutableState<Int>,
|
||||
duration: MutableState<Int>,
|
||||
resetOnStop: Boolean = false
|
||||
) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
val realDuration = start(filePath ?: return, progress.value) { pro ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if (pro == null || pro == duration.value) {
|
||||
audioPlaying.value = false
|
||||
if (resetOnStop) {
|
||||
progress.value = 0
|
||||
} else if (pro == duration.value) {
|
||||
progress.value = duration.value
|
||||
}
|
||||
}
|
||||
}
|
||||
audioPlaying.value = realDuration != null
|
||||
// Update to real duration instead of what was received in ChatInfo
|
||||
realDuration?.let { duration.value = it }
|
||||
}
|
||||
|
||||
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
|
||||
pro.value = pause()
|
||||
audioPlaying.value = false
|
||||
}
|
||||
|
||||
fun duration(filePath: String): Int {
|
||||
var res = 0
|
||||
kotlin.runCatching {
|
||||
helperPlayer.setDataSource(filePath)
|
||||
helperPlayer.prepare()
|
||||
helperPlayer.start()
|
||||
helperPlayer.stop()
|
||||
res = helperPlayer.duration
|
||||
helperPlayer.reset()
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (searchText.text.isNotEmpty()) onValueChange("")
|
||||
}
|
||||
}
|
||||
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -31,6 +32,28 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(),
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionView(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
iconTint: Color = HighOrLowlight,
|
||||
leadingIcon: Boolean = false,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
content: (@Composable ColumnScope.() -> Unit)
|
||||
) {
|
||||
Column {
|
||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
|
||||
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
|
||||
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
|
||||
}
|
||||
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SectionViewSelectable(
|
||||
title: String?,
|
||||
@@ -56,7 +79,12 @@ fun <T> SectionViewSelectable(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
|
||||
fun SectionItemView(
|
||||
click: (() -> Unit)? = null,
|
||||
minHeight: Dp = 46.dp,
|
||||
disabled: Boolean = false,
|
||||
content: (@Composable RowScope.() -> Unit)
|
||||
) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = minHeight)
|
||||
|
||||
@@ -12,21 +12,28 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
|
||||
fun TextEditor(
|
||||
modifier: Modifier,
|
||||
text: MutableState<String>,
|
||||
border: Boolean = true,
|
||||
fontSize: TextUnit = 14.sp,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
onChange: ((String) -> Unit)? = null
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text.value,
|
||||
onValueChange = { text.value = it },
|
||||
onValueChange = { text.value = it; onChange?.invoke(it) },
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace, fontSize = fontSize,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
@@ -37,17 +44,17 @@ fun TextEditor(modifier: Modifier, text: MutableState<String>) {
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
|
||||
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
Modifier.background(background),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp)
|
||||
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.R.attr.factor
|
||||
import android.R.color
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
@@ -14,9 +12,12 @@ import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
@@ -69,6 +70,9 @@ fun getKeyboardState(): State<KeyboardState> {
|
||||
return keyboardState
|
||||
}
|
||||
|
||||
fun hideKeyboard(view: View) =
|
||||
(SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
|
||||
|
||||
// Resource to annotated string from
|
||||
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
|
||||
fun generalGetString(id: Int): String {
|
||||
@@ -215,6 +219,11 @@ private fun spannableStringToAnnotatedString(
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE: Long = 236700
|
||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||
|
||||
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
|
||||
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
@@ -306,6 +315,12 @@ fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, uri: Uri): String? {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
return saveImage(context, bitmap)
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val ext = if (image.hasAlpha()) "png" else "jpg"
|
||||
@@ -432,9 +447,17 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun durationToString(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
|
||||
fun Color.darker(factor: Float = 0.1f): Color =
|
||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||
|
||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
val LongRange.Companion.saver
|
||||
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||
save = { it.value.first to it.value.last },
|
||||
restore = { mutableStateOf(it.first..it.second) }
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -8,6 +9,7 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -60,8 +62,8 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
|
||||
val scope = rememberCoroutineScope()
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val profileImage = remember { mutableStateOf<String?>(null) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
|
||||
import android.content.ClipboardManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -17,6 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
@@ -35,10 +37,21 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
connectViaLink = { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
if (connectViaUri(chatModel, action, uri)) {
|
||||
close()
|
||||
withUriAction(uri) { linkType ->
|
||||
val action = suspend {
|
||||
Log.d(TAG, "connectViaUri: connecting")
|
||||
if (connectViaUri(chatModel, linkType, uri)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
if (linkType == ConnectionLinkType.GROUP) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.connect_via_group_link),
|
||||
text = generalGetString(R.string.you_will_join_group),
|
||||
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||
onConfirm = { withApi { action() } }
|
||||
)
|
||||
} else action()
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
|
||||
import android.Manifest
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -14,12 +15,17 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.json
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Composable
|
||||
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
@@ -33,10 +39,21 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
QRCodeScanner { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
if (connectViaUri(chatModel, action, uri)) {
|
||||
close()
|
||||
withUriAction(uri) { linkType ->
|
||||
val action = suspend {
|
||||
Log.d(TAG, "connectViaUri: connecting")
|
||||
if (connectViaUri(chatModel, linkType, uri)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
if (linkType == ConnectionLinkType.GROUP) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.connect_via_group_link),
|
||||
text = generalGetString(R.string.you_will_join_group),
|
||||
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||
onConfirm = { withApi { action() } }
|
||||
)
|
||||
} else action()
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -49,10 +66,34 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
enum class ConnectionLinkType {
|
||||
CONTACT, INVITATION, GROUP
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CReqClientData {
|
||||
@Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData()
|
||||
}
|
||||
|
||||
fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) {
|
||||
val action = uri.path?.drop(1)?.replace("/", "")
|
||||
if (action == "contact" || action == "invitation") {
|
||||
withApi { run(action) }
|
||||
val data = uri.toString().replaceFirst("#/", "/").toUri().getQueryParameter("data")
|
||||
val type = when {
|
||||
data != null -> {
|
||||
val parsed = runCatching {
|
||||
json.decodeFromString(CReqClientData.serializer(), data)
|
||||
}
|
||||
when {
|
||||
parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
action == "contact" -> ConnectionLinkType.CONTACT
|
||||
action == "invitation" -> ConnectionLinkType.INVITATION
|
||||
else -> null
|
||||
}
|
||||
if (type != null) {
|
||||
withApi { run(type) }
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invalid_contact_link),
|
||||
@@ -61,14 +102,17 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri): Boolean {
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: Uri): Boolean {
|
||||
val r = chatModel.controller.apiConnect(uri.toString())
|
||||
if (r) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.connection_request_sent),
|
||||
text =
|
||||
if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
|
||||
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
|
||||
when (action) {
|
||||
ConnectionLinkType.CONTACT -> generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
|
||||
ConnectionLinkType.INVITATION -> generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
|
||||
ConnectionLinkType.GROUP -> generalGetString(R.string.you_will_be_connected_when_group_host_device_is_online)
|
||||
}
|
||||
)
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel,
|
||||
|
||||
@Composable
|
||||
fun CallSettingsLayout(
|
||||
webrtcPolicyRelay: Preference<Boolean>,
|
||||
callOnLockScreen: Preference<CallOnLockScreen>,
|
||||
webrtcPolicyRelay: SharedPreference<Boolean>,
|
||||
callOnLockScreen: SharedPreference<CallOnLockScreen>,
|
||||
editIceServers: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -79,7 +79,7 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
|
||||
@Composable
|
||||
fun SharedPreferenceToggle(
|
||||
text: String,
|
||||
preference: Preference<Boolean>,
|
||||
preference: SharedPreference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
@@ -106,7 +106,7 @@ fun SharedPreferenceToggleWithIcon(
|
||||
icon: ImageVector,
|
||||
stopped: Boolean = false,
|
||||
onClickInfo: () -> Unit,
|
||||
preference: Preference<Boolean>,
|
||||
preference: SharedPreference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
@@ -135,7 +135,7 @@ fun SharedPreferenceToggleWithIcon(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text)
|
||||
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
||||
|
||||
@@ -3,6 +3,9 @@ package chat.simplex.app.views.usersettings
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
@@ -10,11 +13,24 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
internal class Cryptor {
|
||||
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
private var warningShown = false
|
||||
|
||||
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String {
|
||||
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? {
|
||||
val secretKey = getSecretKey(alias)
|
||||
if (secretKey == null) {
|
||||
if (!warningShown) {
|
||||
// Repeated calls will not show the alert again
|
||||
warningShown = true
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.wrong_passphrase),
|
||||
text = generalGetString(R.string.restore_passphrase_not_found_desc)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
return String(cipher.doFinal(data))
|
||||
}
|
||||
|
||||
@@ -29,7 +45,7 @@ internal class Cryptor {
|
||||
keyStore.deleteEntry(alias)
|
||||
}
|
||||
|
||||
private fun createSecretKey(alias: String): SecretKey {
|
||||
private fun createSecretKey(alias: String): SecretKey? {
|
||||
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
|
||||
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
|
||||
keyGenerator.init(
|
||||
@@ -41,8 +57,8 @@ internal class Cryptor {
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
private fun getSecretKey(alias: String): SecretKey {
|
||||
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
|
||||
private fun getSecretKey(alias: String): SecretKey? {
|
||||
return (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -32,6 +32,10 @@ fun NetworkAndServersView(
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatModel.userSMPServersUnsaved.value = null
|
||||
}
|
||||
|
||||
NetworkAndServersLayout(
|
||||
developerTools = developerTools,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
@@ -112,7 +116,7 @@ fun NetworkAndServersView(
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_and_servers))
|
||||
SectionView(generalGetString(R.string.settings_section_title_messages)) {
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) })
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
|
||||
@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.stop(SimplexApp.context)
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PreferencesView(m: ChatModel, user: User) {
|
||||
var preferences by remember { mutableStateOf(user.fullPreferences) }
|
||||
var currentPreferences by remember { mutableStateOf(preferences) }
|
||||
PreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
|
||||
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
|
||||
if (updatedProfile != null) {
|
||||
val updatedUser = user.copy(
|
||||
profile = updatedProfile.toLocalProfile(user.profile.profileId),
|
||||
fullPreferences = preferences
|
||||
)
|
||||
currentPreferences = preferences
|
||||
m.currentUser.value = updatedUser
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferencesLayout(
|
||||
preferences: FullChatPreferences,
|
||||
currentPreferences: FullChatPreferences,
|
||||
applyPrefs: (FullChatPreferences) -> Unit,
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_preferences))
|
||||
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
|
||||
// FeatureSection(Feature.FullDelete, allowFullDeletion) {
|
||||
// applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
|
||||
// }
|
||||
// SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
|
||||
FeatureSection(Feature.Voice, allowVoice) {
|
||||
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = preferences == currentPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(feature: Feature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
feature.text(),
|
||||
FeatureAllowed.values().map { it to it.text },
|
||||
allowFeature,
|
||||
icon = feature.icon(false),
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.allowDescription(allowFeature.value))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,74 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
fun PrivacySettingsView(
|
||||
chatModel: ChatModel,
|
||||
setPerformLA: (Boolean) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
|
||||
AppBarTitle(stringResource(R.string.your_privacy))
|
||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
SectionDivider()
|
||||
if (chatModel.controller.appPrefs.developerTools.get()) {
|
||||
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
|
||||
SectionDivider()
|
||||
}
|
||||
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
SectionDivider()
|
||||
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
|
||||
simplexLinkMode.set(it)
|
||||
chatModel.simplexLinkMode.value = it
|
||||
}) }
|
||||
}
|
||||
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
|
||||
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
|
||||
val values = remember {
|
||||
SimplexLinkMode.values().map {
|
||||
when (it) {
|
||||
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
|
||||
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
|
||||
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.simplex_link_mode),
|
||||
values,
|
||||
simplexLinkModeState,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
SMPServerLayout(
|
||||
testing,
|
||||
server,
|
||||
testServer = {
|
||||
testing = true
|
||||
scope.launch {
|
||||
val res = testServerConnection(server, m)
|
||||
if (isActive) {
|
||||
onUpdate(res.first)
|
||||
testing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate,
|
||||
onDelete
|
||||
)
|
||||
if (testing) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SMPServerLayout(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server))
|
||||
|
||||
if (server.preset) {
|
||||
PresetServer(testing, server, testServer, onUpdate, onDelete)
|
||||
} else {
|
||||
CustomServer(testing, server, testServer, onUpdate, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetServer(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
server.server,
|
||||
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomServer(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val serverAddress = remember { mutableStateOf(server.server) }
|
||||
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
|
||||
SectionView(
|
||||
stringResource(R.string.smp_servers_your_server_address).uppercase(),
|
||||
icon = Icons.Outlined.ErrorOutline,
|
||||
iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent,
|
||||
) {
|
||||
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
|
||||
TextEditor(
|
||||
Modifier.height(144.dp),
|
||||
text = serverAddress,
|
||||
border = false,
|
||||
fontSize = 16.sp,
|
||||
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
|
||||
) {
|
||||
testedPreviously[server.server] = server.tested
|
||||
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
|
||||
SectionSpacer()
|
||||
|
||||
if (valid.value) {
|
||||
SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) {
|
||||
QRCode(serverAddress.value, Modifier.aspectRatio(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseServerSection(
|
||||
valid: Boolean,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) {
|
||||
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
|
||||
Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||
ShowTestStatus(server)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
val enabled = rememberUpdatedState(server.enabled)
|
||||
PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
|
||||
when (server.tested) {
|
||||
true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen)
|
||||
false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error)
|
||||
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
|
||||
}
|
||||
|
||||
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
|
||||
try {
|
||||
val r = m.controller.testSMPServer(server.server)
|
||||
server.copy(tested = r == null) to r
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
|
||||
server.copy(tested = false) to null
|
||||
}
|
||||
|
||||
fun serverHostname(srv: ServerCfg): String =
|
||||
parseServerAddress(srv.server)?.hostnames?.firstOrNull() ?: srv.server
|
||||
@@ -1,258 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionItemViewSpaceBetween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.OpenInNew
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(chatModel: ChatModel) {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
||||
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
||||
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
||||
fun saveSMPServers(smpServers: List<String>) {
|
||||
withApi {
|
||||
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
||||
if (r) {
|
||||
chatModel.userSMPServers.value = smpServers
|
||||
if (smpServers.isEmpty()) {
|
||||
isUserSMPServers = false
|
||||
editSMPServers = true
|
||||
} else {
|
||||
editSMPServers = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = isUserSMPServers,
|
||||
editSMPServers = editSMPServers,
|
||||
userSMPServersStr = userSMPServersStr,
|
||||
isUserSMPServersOnOff = { switch ->
|
||||
if (switch) {
|
||||
isUserSMPServers = true
|
||||
} else {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
if (userSMPServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.use_simplex_chat_servers__question),
|
||||
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
saveSMPServers(listOf())
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr.value = ""
|
||||
}
|
||||
)
|
||||
} else {
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelEdit = {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
isUserSMPServers = userSMPServers.isNotEmpty()
|
||||
editSMPServers = !isUserSMPServers
|
||||
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
|
||||
}
|
||||
},
|
||||
saveSMPServers = { saveSMPServers(it) },
|
||||
editOn = { editSMPServers = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SMPServersLayout(
|
||||
isUserSMPServers: Boolean,
|
||||
editSMPServers: Boolean,
|
||||
userSMPServersStr: MutableState<String>,
|
||||
isUserSMPServersOnOff: (Boolean) -> Unit,
|
||||
cancelEdit: () -> Unit,
|
||||
saveSMPServers: (List<String>) -> Unit,
|
||||
editOn: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
SectionItemViewSpaceBetween(padding = PaddingValues()) {
|
||||
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
|
||||
Switch(
|
||||
checked = isUserSMPServers,
|
||||
onCheckedChange = isUserSMPServersOnOff,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUserSMPServers) {
|
||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
||||
} else {
|
||||
Text(stringResource(R.string.enter_one_SMP_server_per_line))
|
||||
if (editSMPServers) {
|
||||
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.cancel_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.save_servers_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
val servers = userSMPServersStr.value.split("\n")
|
||||
saveSMPServers(servers)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(160.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
SelectionContainer(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
userSMPServersStr.value,
|
||||
Modifier
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
stringResource(R.string.edit_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
|
||||
) {
|
||||
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(horizontal = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutDefaultServers() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = false,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = remember { mutableStateOf("") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOn() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOff() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = false,
|
||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(m: ChatModel) {
|
||||
var servers by remember {
|
||||
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
|
||||
}
|
||||
val testing = rememberSaveable { mutableStateOf(false) }
|
||||
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
|
||||
val saveDisabled = remember {
|
||||
derivedStateOf {
|
||||
servers.isEmpty() || servers == m.userSMPServers.value || testing.value || !servers.all { srv ->
|
||||
val address = parseServerAddress(srv.server)
|
||||
address != null && uniqueAddress(srv, address, servers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showServer(server: ServerCfg) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
var old by remember { mutableStateOf(server) }
|
||||
val index = servers.indexOf(old)
|
||||
SMPServerView(
|
||||
m,
|
||||
old,
|
||||
onUpdate = { updated ->
|
||||
val newServers = ArrayList(servers)
|
||||
newServers.removeAt(index)
|
||||
newServers.add(index, updated)
|
||||
old = updated
|
||||
servers = newServers
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
},
|
||||
onDelete = {
|
||||
val newServers = ArrayList(servers)
|
||||
newServers.removeAt(index)
|
||||
servers = newServers
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
SMPServersLayout(
|
||||
testing.value,
|
||||
servers,
|
||||
serversUnchanged.value,
|
||||
saveDisabled.value,
|
||||
addServer = {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(R.string.smp_servers_add),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = servers + ServerCfg.empty
|
||||
// No saving until something will be changed on the next screen to prevent blank servers on the list
|
||||
showServer(servers.last())
|
||||
}) {
|
||||
Text(stringResource(R.string.smp_servers_enter_manually))
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ScanSMPServer {
|
||||
close()
|
||||
servers = servers + it
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.smp_servers_scan_qr))
|
||||
}
|
||||
val hasAllPresets = hasAllPresets(servers, m)
|
||||
if (!hasAllPresets) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
|
||||
}) {
|
||||
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
testServers = {
|
||||
scope.launch {
|
||||
testServers(testing, servers, m) {
|
||||
servers = it
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
}
|
||||
}
|
||||
},
|
||||
resetServers = {
|
||||
servers = m.userSMPServers.value ?: emptyList()
|
||||
m.userSMPServersUnsaved.value = null
|
||||
},
|
||||
saveSMPServers = {
|
||||
saveSMPServers(servers, m)
|
||||
},
|
||||
showServer = ::showServer,
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SMPServersLayout(
|
||||
testing: Boolean,
|
||||
servers: List<ServerCfg>,
|
||||
serversUnchanged: Boolean,
|
||||
saveDisabled: Boolean,
|
||||
addServer: () -> Unit,
|
||||
testServers: () -> Unit,
|
||||
resetServers: () -> Unit,
|
||||
saveSMPServers: () -> Unit,
|
||||
showServer: (ServerCfg) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
||||
|
||||
SectionView(stringResource(R.string.smp_servers).uppercase()) {
|
||||
for (srv in servers) {
|
||||
SectionItemView({ showServer(srv) }, disabled = testing) {
|
||||
SmpServerView(srv, servers, testing)
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Add,
|
||||
stringResource(R.string.smp_servers_add),
|
||||
addServer,
|
||||
disabled = testing,
|
||||
textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary,
|
||||
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
SectionItemView(resetServers, disabled = serversUnchanged) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(testServers, disabled = testing) {
|
||||
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testing) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(saveSMPServers, disabled = saveDisabled) {
|
||||
Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
HowToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
|
||||
val address = parseServerAddress(srv.server)
|
||||
when {
|
||||
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
|
||||
else -> ShowTestStatus(srv)
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
val text = address?.hostnames?.firstOrNull() ?: srv.server
|
||||
if (srv.enabled) {
|
||||
Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1)
|
||||
} else {
|
||||
Text(text, maxLines = 1, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HowToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.OpenInNew,
|
||||
stringResource(R.string.how_to_use_your_servers),
|
||||
{ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvalidServer() {
|
||||
Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error)
|
||||
}
|
||||
|
||||
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
|
||||
address.hostnames.all { host ->
|
||||
srv.id == s.id || !srv.server.contains(host)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
|
||||
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
|
||||
|
||||
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
|
||||
val toAdd = ArrayList<ServerCfg>()
|
||||
for (srv in m.presetSMPServers.value ?: emptyList()) {
|
||||
if (!hasPreset(srv, servers)) {
|
||||
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
|
||||
}
|
||||
}
|
||||
return toAdd
|
||||
}
|
||||
|
||||
private fun hasPreset(srv: String, servers: List<ServerCfg>): Boolean =
|
||||
servers.any { it.server == srv }
|
||||
|
||||
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
|
||||
val resetStatus = resetTestStatus(servers)
|
||||
onUpdated(resetStatus)
|
||||
testing.value = true
|
||||
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
|
||||
testing.value = false
|
||||
if (fs.isNotEmpty()) {
|
||||
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.smp_servers_test_failed),
|
||||
text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
|
||||
val copy = ArrayList(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
copy.removeAt(index)
|
||||
copy.add(index, server.copy(tested = null))
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
|
||||
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
|
||||
val updatedServers = ArrayList<ServerCfg>(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
val (updatedServer, f) = testServerConnection(server, m)
|
||||
updatedServers.removeAt(index)
|
||||
updatedServers.add(index, updatedServer)
|
||||
// toList() is important. Otherwise, Compose will not redraw the screen after first update
|
||||
onUpdated(updatedServers.toList())
|
||||
if (f != null) {
|
||||
fs[serverHostname(updatedServer)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
|
||||
withApi {
|
||||
if (m.controller.setUserSMPServers(servers)) {
|
||||
m.userSMPServers.value = servers
|
||||
m.userSMPServersUnsaved.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.app.model.ServerCfg
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun ScanSMPServer(onNext: (ServerCfg) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanSMPServerLayout(onNext)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
.padding(bottom = 12.dp)
|
||||
) {
|
||||
QRCodeScanner { text ->
|
||||
val res = parseServerAddress(text)
|
||||
if (res != null) {
|
||||
onNext(ServerCfg(text, false, null, true))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.smp_servers_invalid_address),
|
||||
text = generalGetString(R.string.smp_servers_check_address)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,8 @@ fun SettingsLayout(
|
||||
stopped: Boolean,
|
||||
encrypted: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: Preference<Boolean>,
|
||||
developerTools: Preference<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
developerTools: SharedPreference<Boolean>,
|
||||
userDisplayName: String,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
@@ -116,20 +116,22 @@ fun SettingsLayout(
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
ChatPreferencesItem(showSettingsModal)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
||||
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
@@ -146,13 +148,25 @@ fun SettingsLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_support)) {
|
||||
ContributeItem(uriHandler)
|
||||
SectionDivider()
|
||||
RateAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
StarOnGithubItem(uriHandler)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
ChatConsoleItem(showTerminal)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools)
|
||||
SectionDivider()
|
||||
if (devTools.value) {
|
||||
ChatConsoleItem(showTerminal)
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
}
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
// SectionDivider()
|
||||
AppVersionItem()
|
||||
@@ -163,7 +177,7 @@ fun SettingsLayout(
|
||||
|
||||
@Composable
|
||||
fun SettingsIncognitoActionItem(
|
||||
incognitoPref: Preference<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
incognito: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
@@ -225,6 +239,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.chat_preferences),
|
||||
click = {
|
||||
withApi {
|
||||
showSettingsModal {
|
||||
PreferencesView(it, it.currentUser.value ?: return@showSettingsModal)
|
||||
}()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -252,6 +280,46 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ContributeItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#contribute") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Keyboard,
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.contribute), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun RateAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({
|
||||
runCatching { uriHandler.openUri("market://details?id=chat.simplex.app") }
|
||||
.onFailure { uriHandler.openUri("https://play.google.com/store/apps/details?id=chat.simplex.app") }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.StarOutline,
|
||||
contentDescription = "Google Play",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.rate_the_app), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun StarOnGithubItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.star_on_github), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SectionItemView(showTerminal) {
|
||||
Icon(
|
||||
@@ -313,8 +381,8 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||
SectionItemView() {
|
||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: SharedPreference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
@@ -330,7 +398,7 @@ fun SettingsPreferenceItemWithInfo(
|
||||
text: String,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
pref: Preference<Boolean>,
|
||||
pref: SharedPreference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null
|
||||
) {
|
||||
SectionItemView(onClickInfo) {
|
||||
@@ -343,20 +411,42 @@ fun SettingsPreferenceItemWithInfo(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceToggleWithIcon(
|
||||
fun PreferenceToggle(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
iconColor: Color = HighOrLowlight,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
tint = iconColor
|
||||
Text(text)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceToggleWithIcon(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
iconColor: Color? = HighOrLowlight,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
tint = iconColor ?: HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
Text(text)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
@@ -386,8 +476,8 @@ fun PreviewSettingsLayout() {
|
||||
stopped = false,
|
||||
encrypted = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = Preference({ false }, {}),
|
||||
developerTools = Preference({ false }, {}),
|
||||
incognitoPref = SharedPreference({ false }, {}),
|
||||
developerTools = SharedPreference({ false }, {}),
|
||||
userDisplayName = "Alice",
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -34,7 +36,7 @@ import kotlinx.coroutines.launch
|
||||
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
val editProfile = remember { mutableStateOf(false) }
|
||||
val editProfile = rememberSaveable { mutableStateOf(false) }
|
||||
var profile by remember { mutableStateOf(user.profile.toProfile()) }
|
||||
UserProfileLayout(
|
||||
editProfile = editProfile,
|
||||
@@ -67,8 +69,8 @@ fun UserProfileLayout(
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(profile.displayName) }
|
||||
val fullName = remember { mutableStateOf(profile.fullName) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val profileImage = remember { mutableStateOf(profile.image) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(profile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<!-- Connect via Link - MainActivity.kt -->
|
||||
<string name="connect_via_contact_link">Über den Kontakt-Link verbinden?</string>
|
||||
<string name="connect_via_invitation_link">Über den Einladungs-Link verbinden?</string>
|
||||
<string name="connect_via_group_link">Über den Gruppen-Link verbinden?</string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Ihr Profil wird an den Kontakt gesendet von dem Sie diesen Link erhalten haben.</string>
|
||||
<string name="you_will_join_group">Sie werden der Gruppe beitreten, auf die sich dieser Link bezieht und sich mit deren Gruppenmitgliedern verbinden.</string>
|
||||
<string name="connect_via_link_verb">Verbinden</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
@@ -39,6 +41,17 @@
|
||||
<string name="description_via_one_time_link">über einen Einmal-Link</string>
|
||||
<string name="description_via_one_time_link_incognito">Inkognito über einen Einmal-Link</string>
|
||||
|
||||
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||
<string name="simplex_link_contact">SimpleX Kontaktadressen-Link</string>
|
||||
<string name="simplex_link_invitation">SimpleX Einmal-Link</string>
|
||||
<string name="simplex_link_group">SimpleX Gruppen-Link</string>
|
||||
<string name="simplex_link_connection">über <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||
<string name="simplex_link_mode">SimpleX-Links</string>
|
||||
<string name="simplex_link_mode_description">Beschreibung</string>
|
||||
<string name="simplex_link_mode_full">Vollständiger Link</string>
|
||||
<string name="simplex_link_mode_browser">Über den Browser</string>
|
||||
<string name="simplex_link_mode_browser_warning">Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein.</string>
|
||||
|
||||
<!-- SimpleXAPI.kt -->
|
||||
<string name="error_saving_smp_servers">Fehler beim Speichern der SMP-Server</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind.</string>
|
||||
@@ -56,7 +69,7 @@
|
||||
<string name="error_receiving_file">Fehler beim Empfangen der Datei</string>
|
||||
<string name="error_creating_address">Fehler beim Erstellen der Adresse</string>
|
||||
<string name="contact_already_exists">Kontakt ist bereits vorhanden</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Sie sind bereits über diesen Link mit <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> verbunden.</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Sie sind bereits mit <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> verbunden.</string>
|
||||
<string name="invalid_connection_link">Ungültiger Verbindungslink</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Überprüfen Sie bitte, ob Sie den richtigen Link genutzt haben oder bitten Sie Ihren Kontakt darum, Ihnen nochmal einen Link zuzusenden.</string>
|
||||
<string name="connection_error_auth">Verbindungsfehler (AUTH)</string>
|
||||
@@ -68,6 +81,14 @@
|
||||
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontaktanfrage</string>
|
||||
<string name="error_deleting_pending_contact_connection">Fehler beim Löschen der anstehenden Kontaktaufnahme</string>
|
||||
<string name="error_changing_address">Fehler beim Wechseln der Adresse</string>
|
||||
<string name="error_smp_test_failed_at_step">Der Test ist beim Schritt %s fehlgeschlagen.</string>
|
||||
<string name="error_smp_test_server_auth">Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort.</string>
|
||||
<string name="error_smp_test_certificate">Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig.</string>
|
||||
<string name="smp_server_test_connect">Verbinde</string>
|
||||
<string name="smp_server_test_create_queue">Erzeuge Warteschlange</string>
|
||||
<string name="smp_server_test_secure_queue">Sichere Warteschlange</string>
|
||||
<string name="smp_server_test_delete_queue">Lösche Warteschlange</string>
|
||||
<string name="smp_server_test_disconnect">Trenne Verbindung</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Sofortige Benachrichtigungen</string>
|
||||
@@ -129,16 +150,16 @@
|
||||
<string name="auth_enable_simplex_lock">SimpleX Sperre aktivieren</string>
|
||||
<string name="auth_disable_simplex_lock">SimpleX Sperre deaktivieren</string>
|
||||
<string name="auth_confirm_credential">Bestätigen Sie Ihre Zugangsdaten</string>
|
||||
<string name="auth_error">Authentifizierungsfehler</string>
|
||||
<string name="auth_error_w_desc">Authentifizierungsfehler: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||
<string name="auth_failed">Authentifizierung fehlgeschlagen</string>
|
||||
<string name="auth_unavailable">Authentifizierung nicht verfügbar</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Geräteauthentifizierung ist nicht aktiviert. Sie können die SimpleX Sperre über die Einstellungen aktivieren, sobald Sie die Geräteauthentifizierung aktiviert haben.</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Geräteauthentifizierung ist deaktiviert. SimpleX Sperre ist abgeschaltet.</string>
|
||||
<string name="auth_retry">Wiederholen</string>
|
||||
<string name="auth_stop_chat">Chat beenden</string>
|
||||
<string name="auth_open_chat_console">Chat-Konsole öffnen</string>
|
||||
|
||||
<!-- Chat Alerts - ChatItemView.kt -->
|
||||
<string name="message_delivery_error_title">Fehler bei der Nachrichtenzustellung</string>
|
||||
<string name="message_delivery_error_desc">Dieser Kontakt hat sehr wahrscheinlich die Verbindung mit Ihnen gelöscht.</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Antwort</string>
|
||||
<string name="share_verb">Teilen</string>
|
||||
@@ -146,6 +167,7 @@
|
||||
<string name="save_verb">Speichern</string>
|
||||
<string name="edit_verb">Bearbeiten</string>
|
||||
<string name="delete_verb">Löschen</string>
|
||||
<string name="allow_verb">Erlauben</string>
|
||||
<string name="delete_message__question">Die Nachricht löschen?</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
|
||||
<string name="for_me_only">Nur für mich</string>
|
||||
@@ -183,6 +205,8 @@
|
||||
<string name="icon_descr_cancel_file_preview">Dateivorschau abbrechen</string>
|
||||
<string name="images_limit_title">Zu viele Bilder!</string>
|
||||
<string name="images_limit_desc">Es können nur 10 Bilder auf einmal gesendet werden</string>
|
||||
<string name="image_decoding_exception_title">Dekodierungsfehler</string>
|
||||
<string name="image_decoding_exception_desc">Das Bild kann nicht dekodiert werden. Bitte versuchen Sie es mit einem anderen Bild oder wenden Sie sich an die Entwickler.</string>
|
||||
|
||||
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
|
||||
<string name="image_descr">Bild</string>
|
||||
@@ -204,6 +228,10 @@
|
||||
<string name="file_not_found">Datei nicht gefunden</string>
|
||||
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Sprachnachricht</string>
|
||||
<string name="voice_message_send_text">Sprachnachricht…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Benachrichtigungen</string>
|
||||
|
||||
@@ -217,15 +245,22 @@
|
||||
<string name="icon_descr_server_status_error">Fehler</string>
|
||||
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
||||
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Address-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Nachricht senden</string>
|
||||
<string name="icon_descr_record_voice_message">Nehme Sprachnachricht auf</string>
|
||||
<string name="allow_voice_messages_question">Sprachnachrichten erlauben?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Sie müssen Ihrem Kontakt das Senden von Sprachnachrichten erlauben, damit Sie sie senden können.</string>
|
||||
<string name="voice_messages_prohibited">Sprachnachrichten unzulässig!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Bitten Sie Ihren Kontakt darum, das Senden von Sprachnachrichten zu aktivieren.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden.</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Zurück</string>
|
||||
<string name="cancel_verb">Abbrechen</string>
|
||||
<string name="confirm_verb">Bestätigen</string>
|
||||
<string name="reset_verb">Zurücksetzen</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="no_details">Keine Details</string>
|
||||
<string name="add_contact">Kontakt hinzufügen</string>
|
||||
@@ -354,19 +389,39 @@
|
||||
<string name="how_to_use_simplex_chat">Wie man SimpleX nutzt</string>
|
||||
<string name="markdown_help">Markdown Hilfe</string>
|
||||
<string name="markdown_in_messages">Markdown in Nachrichten</string>
|
||||
<string name="chat_with_the_founder">Verbinden Sie sich mit den Entwicklern</string>
|
||||
<string name="chat_with_the_founder">Senden Sie Fragen und Ideen</string>
|
||||
<string name="send_us_an_email">Senden Sie uns eine E-Mail</string>
|
||||
<string name="chat_lock">SimpleX Sperre</string>
|
||||
<string name="chat_console">Chat Konsole</string>
|
||||
<string name="smp_servers">SMP-Server</string>
|
||||
<string name="smp_servers_preset_address">Voreingestellte Serveradresse</string>
|
||||
<string name="smp_servers_preset_add">Füge voreingestellte Server hinzu</string>
|
||||
<string name="smp_servers_add">Füge Server hinzu…</string>
|
||||
<string name="smp_servers_test_server">Teste Server</string>
|
||||
<string name="smp_servers_test_servers">Teste alle Server</string>
|
||||
<string name="smp_servers_save">Sichere alle Server</string>
|
||||
<string name="smp_servers_test_failed">Server Test ist fehlgeschlagen!</string>
|
||||
<string name="smp_servers_test_some_failed">Einige Server haben den Test nicht bestanden:</string>
|
||||
<string name="smp_servers_scan_qr">Scannen Sie den QR-Code des Servers</string>
|
||||
<string name="smp_servers_enter_manually">Geben Sie den Server manuell ein</string>
|
||||
<string name="smp_servers_preset_server">Voreingestellter Server</string>
|
||||
<string name="smp_servers_your_server">Ihr Server</string>
|
||||
<string name="smp_servers_your_server_address">Ihre Serveradresse</string>
|
||||
<string name="smp_servers_use_server">Server nutzen</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Für neue Verbindungen nutzen</string>
|
||||
<string name="smp_servers_add_to_another_device">Einem anderen Gerät hinzufügen</string>
|
||||
<string name="smp_servers_invalid_address">Ungültige Serveradresse!</string>
|
||||
<string name="smp_servers_check_address">Überprüfen Sie die Serveradresse und versuchen Sie es nochmal.</string>
|
||||
<string name="smp_servers_delete_server">Server löschen</string>
|
||||
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
||||
<string name="star_on_github">Stern auf GitHub vergeben</string>
|
||||
<string name="contribute">Unterstützen Sie uns</string>
|
||||
<string name="rate_the_app">Bewerten Sie die App</string>
|
||||
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
||||
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
|
||||
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
||||
<string name="configure_SMP_servers">SMP-Server konfigurieren</string>
|
||||
<string name="using_simplex_chat_servers">Verwendung von <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Servern.</string>
|
||||
<string name="enter_one_SMP_server_per_line">SMP-Server (einer pro Zeile)</string>
|
||||
<string name="how_to">Anleitung</string>
|
||||
<string name="how_to_use_your_servers">Wie Sie Ihre Server nutzen</string>
|
||||
<string name="saved_ICE_servers_will_be_removed">Gespeicherte WebRTC ICE-Server werden entfernt.</string>
|
||||
<string name="your_ICE_servers">Ihre ICE-Server</string>
|
||||
<string name="configure_ICE_servers">Konfigurieren Sie ICE-Server</string>
|
||||
@@ -416,7 +471,9 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
|
||||
<string name="edit_image">Bild bearbeiten</string>
|
||||
<string name="delete_image">Bild löschen</string>
|
||||
<string name="save_and_notify_contact">Speichern (und Kontakt benachrichtigen)</string>
|
||||
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
|
||||
<string name="save_and_notify_group_members">Speichern (und Gruppenmitglieder benachrichtigen)</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
|
||||
@@ -475,12 +532,12 @@
|
||||
|
||||
<!-- How SimpleX Works -->
|
||||
<string name="how_simplex_works">Wie <xliff:g id="appName">SimpleX</xliff:g> funktioniert</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wenn <xliff:g id="appName">SimpleX</xliff:g> keine Benutzerkennungen hat, wie kann es dann Nachrichten zustellen?</i></string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzer-IDs, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie legen fest, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b>.</string>
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
|
||||
<string name="read_more_in_github">Erfahren Sie mehr dazu in unserem GitHub-Repository.</string>
|
||||
<string name="read_more_in_github_with_link">Erfahren Sie mehr dazu in unserem <font color="#0088ff">GitHub-Repository</font>.</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wie kann <xliff:g id="appName">SimpleX</xliff:g> Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?</i></string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzerkennungen, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie können selbst festlegen, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b> wollen.</string>
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
|
||||
<string name="read_more_in_github">Erfahren Sie in unserem GitHub-Repository mehr dazu.</string>
|
||||
<string name="read_more_in_github_with_link">Erfahren Sie in unserem <font color="#0088ff">GitHub-Repository</font> mehr dazu.</string>
|
||||
|
||||
<!-- MakeConnection -->
|
||||
<string name="paste_the_link_you_received">Fügen Sie den erhaltenen Link ein</string>
|
||||
@@ -552,14 +609,16 @@
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Datenschutz & Sicherheit</string>
|
||||
<string name="your_privacy">Meine Privatsphäre</string>
|
||||
<string name="protect_app_screen">App-Bildschirm schützen</string>
|
||||
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
|
||||
<string name="transfer_images_faster">Bilder schneller übertragen (BETA)</string>
|
||||
<string name="transfer_images_faster">Bilder schneller übertragen</string>
|
||||
<string name="send_link_previews">Link-Vorschau senden</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">MEINE DATEN</string>
|
||||
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
||||
<string name="settings_section_title_help">HILFE</string>
|
||||
<string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
||||
<string name="settings_section_title_device">GERÄT</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
@@ -674,6 +733,7 @@
|
||||
<string name="restore_database_alert_desc">Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="restore_database_alert_confirm">Wiederherstellen</string>
|
||||
<string name="database_restore_error">Fehler bei der Wiederherstellung der Datenbank</string>
|
||||
<string name="restore_passphrase_not_found_desc">Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist.</string>
|
||||
|
||||
<!-- ChatModel.chatRunning interactions -->
|
||||
<string name="chat_is_stopped_indication">Chat wurde beendet</string>
|
||||
@@ -791,6 +851,7 @@
|
||||
<string name="all_group_members_will_remain_connected">Alle Gruppenmitglieder bleiben verbunden.</string>
|
||||
<string name="error_creating_link_for_group">Fehler beim Erzeugen des Gruppen-Links</string>
|
||||
<string name="error_deleting_link_for_group">Fehler beim Löschen des Gruppen-Links</string>
|
||||
<string name="only_group_owners_can_change_prefs">Gruppenpräferenzen können nur von Gruppen-Eigentümern geändert werden.</string>
|
||||
|
||||
<!-- For Console chat info section -->
|
||||
<string name="section_title_for_console">FÜR KONSOLE</string>
|
||||
@@ -822,7 +883,7 @@
|
||||
<string name="receiving_via">Empfangen über</string>
|
||||
<string name="sending_via">Senden über</string>
|
||||
<string name="network_status">Netzwerkstatus</string>
|
||||
<string name="switch_receiving_address">Wechseln der Empfängeradresse (BETA)</string>
|
||||
<string name="switch_receiving_address">Wechseln der Empfängeradresse</string>
|
||||
|
||||
<!-- AddGroupView.kt -->
|
||||
<string name="create_secret_group_title">Geheime Gruppe erstellen</string>
|
||||
@@ -872,4 +933,48 @@
|
||||
<string name="save_color">Farbe speichern</string>
|
||||
<string name="reset_color">Farben zurücksetzen</string>
|
||||
<string name="color_primary">Akzent</string>
|
||||
|
||||
<!-- Preferences.kt -->
|
||||
<string name="chat_preferences_you_allow">Sie erlauben</string>
|
||||
<string name="chat_preferences_contact_allows">Der Kontakt erlaubt</string>
|
||||
<string name="chat_preferences_default">Voreinstellung (%s)</string>
|
||||
<string name="chat_preferences_yes">Ja</string>
|
||||
<string name="chat_preferences_no">Nein</string>
|
||||
<string name="chat_preferences_always">Immer</string>
|
||||
<string name="chat_preferences_on">Ein</string>
|
||||
<string name="chat_preferences_off">Aus</string>
|
||||
<string name="chat_preferences">Chat Präferenzen</string>
|
||||
<string name="contact_preferences">Kontakt Präferenzen</string>
|
||||
<string name="group_preferences">Gruppen Präferenzen</string>
|
||||
<string name="your_preferences">Ihre Präferenzen</string>
|
||||
<string name="full_deletion">Vollständige Löschung</string>
|
||||
<string name="voice_messages">Sprachnachrichten</string>
|
||||
<string name="feature_enabled">aktiviert</string>
|
||||
<string name="feature_enabled_for_you">Für Sie aktiviert</string>
|
||||
<string name="feature_enabled_for_contact">FÜr Kontakt aktiviert</string>
|
||||
<string name="feature_off">Aus</string>
|
||||
<string name="feature_received_prohibited">empfangen, nicht erlaubt</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Erlauben Sie Ihren Kontakten gesendete Nachrichten unwiederbringlich zu löschen.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Erlauben Sie Ihre Kontakten Sprachnachrichten zu senden.</string>
|
||||
<string name="allow_voice_messages_only_if">Erlauben Sie Sprachnachrichten nur dann, wenn Ihr Kontakt diese ebenfalls erlaubt.</string>
|
||||
<string name="prohibit_sending_voice_messages">Das Senden von Sprachnachrichten verbieten.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Sowohl Ihr Kontakt, als auch Sie können Nachrichten unwiederbringlich löschen.</string>
|
||||
<string name="only_you_can_delete_messages">Nur Sie können Nachrichten unwiederbringlich löschen (Ihr Kontakt kann sie zum Löschen markieren).</string>
|
||||
<string name="only_your_contact_can_delete">Nur Ihr Kontakt kann Nachrichten unwiederbringlich löschen (Sie können sie zum Löschen markieren).</string>
|
||||
<string name="message_deletion_prohibited">In diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden.</string>
|
||||
<string name="only_you_can_send_voice">Nur Sie können Sprachnachrichten senden.</string>
|
||||
<string name="only_your_contact_can_send_voice">Nur Ihr Kontakt kann Sprachnachrichten senden.</string>
|
||||
<string name="voice_prohibited_in_this_chat">In diesem Chat sind Sprachnachrichten untersagt.</string>
|
||||
<string name="allow_to_delete_messages">Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</string>
|
||||
<string name="prohibit_message_deletion">Unwiederbringliches Löschen von Nachrichten verbieten.</string>
|
||||
<string name="allow_to_send_voice">Senden von Sprachnachrichten erlauben.</string>
|
||||
<string name="prohibit_sending_voice">Senden von Sprachnachrichten untersagen.</string>
|
||||
<string name="group_members_can_delete">Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">In diesem Chat ist das unwiederbringliche Löschen von Nachrichten verboten.</string>
|
||||
<string name="group_members_can_send_voice">Gruppenmitglieder können Sprachnachrichten senden.</string>
|
||||
<string name="voice_messages_are_prohibited">In diesem Chat sind Sprachnachrichten untersagt.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<!-- Connect via Link - MainActivity.kt -->
|
||||
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
|
||||
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
|
||||
<string name="connect_via_group_link">Соединиться через ссылку группы?</string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
|
||||
<string name="you_will_join_group">Вы вступите в группу, на которую ссылается эта ссылка.</string>
|
||||
<string name="connect_via_link_verb">Соединиться</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
@@ -39,6 +41,17 @@
|
||||
<string name="description_via_one_time_link">через одноразовую ссылку</string>
|
||||
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
|
||||
|
||||
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||
<string name="simplex_link_contact">SimpleX ссылка-контакт</string>
|
||||
<string name="simplex_link_invitation">SimpleX одноразовая ссылка</string>
|
||||
<string name="simplex_link_group">SimpleX ссылка группы</string>
|
||||
<string name="simplex_link_connection">через <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||
<string name="simplex_link_mode">SimpleX ссылки</string>
|
||||
<string name="simplex_link_mode_description">Описание</string>
|
||||
<string name="simplex_link_mode_full">Полная ссылка</string>
|
||||
<string name="simplex_link_mode_browser">В браузере</string>
|
||||
<string name="simplex_link_mode_browser_warning">Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными.</string>
|
||||
|
||||
<!-- SimpleXAPI.kt -->
|
||||
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
|
||||
@@ -56,7 +69,7 @@
|
||||
<string name="error_receiving_file">Ошибка при получении файла</string>
|
||||
<string name="error_creating_address">Ошибка при создании адреса</string>
|
||||
<string name="contact_already_exists">Существующий контакт</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> через эту ссылку.</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с контактом <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
|
||||
<string name="invalid_connection_link">Ошибка в ссылке контакта</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую.</string>
|
||||
<string name="connection_error_auth">Ошибка соединения (AUTH)</string>
|
||||
@@ -68,6 +81,14 @@
|
||||
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
||||
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
||||
<string name="error_changing_address">Ошибка при изменении адреса</string>
|
||||
<string name="error_smp_test_failed_at_step">Ошибка теста на шаге %s.</string>
|
||||
<string name="error_smp_test_server_auth">Сервер требует авторизации для создания очередей, проверьте пароль</string>
|
||||
<string name="error_smp_test_certificate">Возможно, хэш сертификата в адресе сервера неверный</string>
|
||||
<string name="smp_server_test_connect">Соединение</string>
|
||||
<string name="smp_server_test_create_queue">Создание очереди</string>
|
||||
<string name="smp_server_test_secure_queue">Защита очереди</string>
|
||||
<string name="smp_server_test_delete_queue">Удаление очереди</string>
|
||||
<string name="smp_server_test_disconnect">Разрыв соединения</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||
@@ -129,16 +150,16 @@
|
||||
<string name="auth_enable_simplex_lock">Включить блокировку SimpleX</string>
|
||||
<string name="auth_disable_simplex_lock">Отключить блокировку SimpleX</string>
|
||||
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
|
||||
<string name="auth_error">Ошибка аутентификации</string>
|
||||
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||
<string name="auth_failed">Ошибка аутентификации</string>
|
||||
<string name="auth_unavailable">Аутентификация недоступна</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
|
||||
<string name="auth_retry">Повторить</string>
|
||||
<string name="auth_stop_chat">Остановить чат</string>
|
||||
<string name="auth_open_chat_console">Открыть консоль</string>
|
||||
|
||||
<!-- Chat Alerts - ChatItemView.kt -->
|
||||
<string name="message_delivery_error_title">Ошибка доставки сообщения</string>
|
||||
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с вами.</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Ответить</string>
|
||||
<string name="share_verb">Поделиться</string>
|
||||
@@ -146,6 +167,7 @@
|
||||
<string name="save_verb">Сохранить</string>
|
||||
<string name="edit_verb">Редактировать</string>
|
||||
<string name="delete_verb">Удалить</string>
|
||||
<string name="allow_verb">Разрешить</string>
|
||||
<string name="delete_message__question">Удалить сообщение?</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено – это действие нельзя отменить!</string>
|
||||
<string name="for_me_only">Только для меня</string>
|
||||
@@ -183,6 +205,8 @@
|
||||
<string name="icon_descr_cancel_file_preview">Удалить превью файла</string>
|
||||
<string name="images_limit_title">Слишком много изображений!</string>
|
||||
<string name="images_limit_desc">Только 10 изображений могут быть отправлены одномоментно</string>
|
||||
<string name="image_decoding_exception_title">Ошибка декодирования</string>
|
||||
<string name="image_decoding_exception_desc">Не получается декодировать изображение. Пожалуйста, попробуйте другое изображение или свяжитесь с разработчиками.</string>
|
||||
|
||||
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
|
||||
<string name="image_descr">Изображение</string>
|
||||
@@ -204,6 +228,10 @@
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Уведомления</string>
|
||||
|
||||
@@ -221,11 +249,18 @@
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||
<string name="allow_voice_messages_question">Разрешить голосовые сообщения?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их вашему контакту.</string>
|
||||
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Назад</string>
|
||||
<string name="cancel_verb">Отменить</string>
|
||||
<string name="confirm_verb">Подтвердить</string>
|
||||
<string name="reset_verb">Сбросить</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="no_details">нет описания</string>
|
||||
<string name="add_contact">Одноразовая ссылка</string>
|
||||
@@ -351,19 +386,39 @@
|
||||
<string name="how_to_use_simplex_chat">Как использовать</string>
|
||||
<string name="markdown_help">Форматирование сообщений</string>
|
||||
<string name="markdown_in_messages">Форматирование сообщений</string>
|
||||
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
|
||||
<string name="chat_with_the_founder">Отправьте вопросы и идеи</string>
|
||||
<string name="send_us_an_email">Отправить email</string>
|
||||
<string name="chat_lock">Блокировка SimpleX</string>
|
||||
<string name="chat_console">Консоль</string>
|
||||
<string name="smp_servers">SMP серверы</string>
|
||||
<string name="smp_servers_preset_address">Адрес сервера по умолчанию</string>
|
||||
<string name="smp_servers_preset_add">Добавить серверы по умолчанию</string>
|
||||
<string name="smp_servers_add">Добавить сервер…</string>
|
||||
<string name="smp_servers_test_server">Тестировать сервер</string>
|
||||
<string name="smp_servers_test_servers">Тестировать серверы</string>
|
||||
<string name="smp_servers_save">Сохранить серверы</string>
|
||||
<string name="smp_servers_test_failed">Ошибка теста сервера!</string>
|
||||
<string name="smp_servers_test_some_failed">Серверы не прошли тест:</string>
|
||||
<string name="smp_servers_scan_qr">Сканировать QR код сервера</string>
|
||||
<string name="smp_servers_enter_manually">Ввести сервер вручную</string>
|
||||
<string name="smp_servers_preset_server">Сервер по умолчанию</string>
|
||||
<string name="smp_servers_your_server">Ваш сервер</string>
|
||||
<string name="smp_servers_your_server_address">Адрес вашего сервера</string>
|
||||
<string name="smp_servers_use_server">Использовать сервер</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Использовать для новых соединений</string>
|
||||
<string name="smp_servers_add_to_another_device">Добавить на другое устройство</string>
|
||||
<string name="smp_servers_invalid_address">Ошибка в адресе сервера!</string>
|
||||
<string name="smp_servers_check_address">Проверьте адрес сервера и попробуйте снова.</string>
|
||||
<string name="smp_servers_delete_server">Удалить сервер</string>
|
||||
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
|
||||
<string name="star_on_github">Поставить звездочку в GitHub</string>
|
||||
<string name="contribute">Внести свой вклад</string>
|
||||
<string name="rate_the_app">Оценить приложение</string>
|
||||
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
|
||||
<string name="saved_SMP_servers_will_be_removed">Сохраненные SMP серверы будут удалены.</string>
|
||||
<string name="your_SMP_servers">Ваши SMP серверы</string>
|
||||
<string name="configure_SMP_servers">Настройка SMP серверов</string>
|
||||
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
||||
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
|
||||
<string name="how_to">Инфо</string>
|
||||
<string name="how_to_use_your_servers">Как использовать серверы</string>
|
||||
<string name="saved_ICE_servers_will_be_removed">Сохраненные WebRTC ICE серверы будут удалены.</string>
|
||||
<string name="your_ICE_servers">Ваши ICE серверы</string>
|
||||
<string name="configure_ICE_servers">Настройка ICE серверов</string>
|
||||
@@ -413,7 +468,9 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
|
||||
<string name="edit_image">Поменять аватар</string>
|
||||
<string name="delete_image">Удалить аватар</string>
|
||||
<string name="save_and_notify_contact">Сохранить (и уведомить контакт)</string>
|
||||
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
|
||||
<string name="save_and_notify_group_members">Сохранить (и уведомить членов группы)</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||
@@ -552,14 +609,16 @@
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Конфиденциальность</string>
|
||||
<string name="your_privacy">Конфиденциальность</string>
|
||||
<string name="protect_app_screen">Защитить экран приложения</string>
|
||||
<string name="auto_accept_images">Автоприем изображений</string>
|
||||
<string name="transfer_images_faster">Передавать изображения быстрее (BETA)</string>
|
||||
<string name="transfer_images_faster">Передавать изображения быстрее</string>
|
||||
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">ВЫ</string>
|
||||
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
|
||||
<string name="settings_section_title_help">ПОМОЩЬ</string>
|
||||
<string name="settings_section_title_support">ПОДДЕРЖАТЬ SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
|
||||
<string name="settings_section_title_device">УСТРОЙСТВО</string>
|
||||
<string name="settings_section_title_chats">ЧАТЫ</string>
|
||||
@@ -674,6 +733,7 @@
|
||||
<string name="restore_database_alert_desc">Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить.</string>
|
||||
<string name="restore_database_alert_confirm">Восстановить</string>
|
||||
<string name="database_restore_error">Ошибка при восстановлении базы данных</string>
|
||||
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</string>
|
||||
|
||||
<!-- ChatModel.chatRunning interactions -->
|
||||
<string name="chat_is_stopped_indication">Чат остановлен</string>
|
||||
@@ -791,6 +851,7 @@
|
||||
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
|
||||
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
|
||||
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
|
||||
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
|
||||
|
||||
<!-- For Console chat info section -->
|
||||
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
|
||||
@@ -822,7 +883,7 @@
|
||||
<string name="receiving_via">Получение через</string>
|
||||
<string name="sending_via">Отправка через</string>
|
||||
<string name="network_status">Состояние сети</string>
|
||||
<string name="switch_receiving_address">Переключить адрес получения (BETA)</string>
|
||||
<string name="switch_receiving_address">Переключить адрес получения</string>
|
||||
|
||||
<!-- AddGroupView.kt -->
|
||||
<string name="create_secret_group_title">Создать скрытую группу</string>
|
||||
@@ -871,4 +932,48 @@
|
||||
<string name="save_color">Сохранить цвет</string>
|
||||
<string name="reset_color">Сбросить цвета</string>
|
||||
<string name="color_primary">Акцент</string>
|
||||
|
||||
<!-- Preferences.kt -->
|
||||
<string name="chat_preferences_you_allow">Вы разрешаете</string>
|
||||
<string name="chat_preferences_contact_allows">Контакт разрешает</string>
|
||||
<string name="chat_preferences_default">по умолчанию (%s)</string>
|
||||
<string name="chat_preferences_yes">да</string>
|
||||
<string name="chat_preferences_no">нет</string>
|
||||
<string name="chat_preferences_always">всегда</string>
|
||||
<string name="chat_preferences_on">да</string>
|
||||
<string name="chat_preferences_off">нет</string>
|
||||
<string name="chat_preferences">Предпочтения</string>
|
||||
<string name="contact_preferences">Предпочтения контакта</string>
|
||||
<string name="group_preferences">Предпочтения группы</string>
|
||||
<string name="your_preferences">Ваши предпочтения</string>
|
||||
<string name="full_deletion">Полное удаление</string>
|
||||
<string name="voice_messages">Голосовые сообщения</string>
|
||||
<string name="feature_enabled">включено</string>
|
||||
<string name="feature_enabled_for_you">включено для вас</string>
|
||||
<string name="feature_enabled_for_contact">включено для контакта</string>
|
||||
<string name="feature_off">выключено</string>
|
||||
<string name="feature_received_prohibited">получено, не разрешено</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Разрешить вашим контактам необратимо удалять отправленные сообщения.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Разрешить вашим контактам отправлять голосовые сообщения.</string>
|
||||
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает ваш контакт.</string>
|
||||
<string name="prohibit_sending_voice_messages">Запретить отправлять голосовые сообщений.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Вы и ваш контакт можете необратимо удалять отправленные сообщения.</string>
|
||||
<string name="only_you_can_delete_messages">Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</string>
|
||||
<string name="only_your_contact_can_delete">Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</string>
|
||||
<string name="message_deletion_prohibited">Необратимое удаление сообщений запрещено в этом чате.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Вы и ваш контакт можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_you_can_send_voice">Только вы можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_your_contact_can_send_voice">Только ваш контакт может отправлять голосовые сообщения.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Голосовые сообщения запрещены в этом чате.</string>
|
||||
<string name="allow_to_delete_messages">Разрешить необратимо удалять отправленные сообщения.</string>
|
||||
<string name="prohibit_message_deletion">Запретить необратимое удаление сообщений.</string>
|
||||
<string name="allow_to_send_voice">Разрешить отправлять голосовые сообщения.</string>
|
||||
<string name="prohibit_sending_voice">Запретить отправлять голосовые сообщений.</string>
|
||||
<string name="group_members_can_delete">Члены группы могут необратимо удалять отправленные сообщения.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этом чате.</string>
|
||||
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
|
||||
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этом чате.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<!-- Connect via Link - MainActivity.kt -->
|
||||
<string name="connect_via_contact_link">Connect via contact link?</string>
|
||||
<string name="connect_via_invitation_link">Connect via invitation link?</string>
|
||||
<string name="connect_via_group_link">Connect via group link?</string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Your profile will be sent to the contact that you received this link from.</string>
|
||||
<string name="you_will_join_group">You will join a group this link refers to and connect to its group members.</string>
|
||||
<string name="connect_via_link_verb">Connect</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
@@ -39,6 +41,17 @@
|
||||
<string name="description_via_one_time_link">via one-time link</string>
|
||||
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
|
||||
|
||||
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||
<string name="simplex_link_contact">SimpleX contact address</string>
|
||||
<string name="simplex_link_invitation">SimpleX one-time invitation</string>
|
||||
<string name="simplex_link_group">SimpleX group link</string>
|
||||
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||
<string name="simplex_link_mode">SimpleX links</string>
|
||||
<string name="simplex_link_mode_description">Description</string>
|
||||
<string name="simplex_link_mode_full">Full link</string>
|
||||
<string name="simplex_link_mode_browser">Via browser</string>
|
||||
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
|
||||
|
||||
<!-- SimpleXAPI.kt -->
|
||||
<string name="error_saving_smp_servers">Error saving SMP servers</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
|
||||
@@ -56,7 +69,7 @@
|
||||
<string name="error_receiving_file">Error receiving file</string>
|
||||
<string name="error_creating_address">Error creating address</string>
|
||||
<string name="contact_already_exists">Contact already exists</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> via this link.</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
|
||||
<string name="invalid_connection_link">Invalid connection link</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
|
||||
<string name="connection_error_auth">Connection error (AUTH)</string>
|
||||
@@ -68,6 +81,14 @@
|
||||
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
||||
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
||||
<string name="error_changing_address">Error changing address</string>
|
||||
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
|
||||
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
|
||||
<string name="error_smp_test_certificate">Possibly, certificate fingerprint in server address is incorrect</string>
|
||||
<string name="smp_server_test_connect">Connect</string>
|
||||
<string name="smp_server_test_create_queue">Create queue</string>
|
||||
<string name="smp_server_test_secure_queue">Secure queue</string>
|
||||
<string name="smp_server_test_delete_queue">Delete queue</string>
|
||||
<string name="smp_server_test_disconnect">Disconnect</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||
@@ -129,16 +150,16 @@
|
||||
<string name="auth_enable_simplex_lock">Enable SimpleX Lock</string>
|
||||
<string name="auth_disable_simplex_lock">Disable SimpleX Lock</string>
|
||||
<string name="auth_confirm_credential">Confirm your credential</string>
|
||||
<string name="auth_error">Authentication error</string>
|
||||
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||
<string name="auth_failed">Authentication failed</string>
|
||||
<string name="auth_unavailable">Authentication unavailable</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
|
||||
<string name="auth_retry">Retry</string>
|
||||
<string name="auth_stop_chat">Stop chat</string>
|
||||
<string name="auth_open_chat_console">Open chat console</string>
|
||||
|
||||
<!-- Chat Alerts - ChatItemView.kt -->
|
||||
<string name="message_delivery_error_title">Message delivery error</string>
|
||||
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Reply</string>
|
||||
<string name="share_verb">Share</string>
|
||||
@@ -146,6 +167,7 @@
|
||||
<string name="save_verb">Save</string>
|
||||
<string name="edit_verb">Edit</string>
|
||||
<string name="delete_verb">Delete</string>
|
||||
<string name="allow_verb">Allow</string>
|
||||
<string name="delete_message__question">Delete message?</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
|
||||
<string name="for_me_only">For me only</string>
|
||||
@@ -183,6 +205,8 @@
|
||||
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
|
||||
<string name="images_limit_title">Too many images!</string>
|
||||
<string name="images_limit_desc">Only 10 images can be sent at the same time</string>
|
||||
<string name="image_decoding_exception_title">Decoding error</string>
|
||||
<string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string>
|
||||
|
||||
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
|
||||
<string name="image_descr">Image</string>
|
||||
@@ -204,6 +228,10 @@
|
||||
<string name="file_not_found">File not found</string>
|
||||
<string name="error_saving_file">Error saving file</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Voice message</string>
|
||||
<string name="voice_message_send_text">Voice message…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Notifications</string>
|
||||
|
||||
@@ -221,11 +249,18 @@
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Send Message</string>
|
||||
<string name="icon_descr_record_voice_message">Record voice message</string>
|
||||
<string name="allow_voice_messages_question">Allow voice messages?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">You need to allow your contact to send voice messages to be able to send them.</string>
|
||||
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Back</string>
|
||||
<string name="cancel_verb">Cancel</string>
|
||||
<string name="confirm_verb">Confirm</string>
|
||||
<string name="reset_verb">Reset</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="no_details">no details</string>
|
||||
<string name="add_contact">One-time invitation link</string>
|
||||
@@ -354,19 +389,39 @@
|
||||
<string name="how_to_use_simplex_chat">How to use it</string>
|
||||
<string name="markdown_help">Markdown help</string>
|
||||
<string name="markdown_in_messages">Markdown in messages</string>
|
||||
<string name="chat_with_the_founder">Connect to the developers</string>
|
||||
<string name="chat_with_the_founder">Send questions and ideas</string>
|
||||
<string name="send_us_an_email">Send us email</string>
|
||||
<string name="chat_lock">SimpleX Lock</string>
|
||||
<string name="chat_console">Chat console</string>
|
||||
<string name="smp_servers">SMP servers</string>
|
||||
<string name="smp_servers_preset_address">Preset server address</string>
|
||||
<string name="smp_servers_preset_add">Add preset servers</string>
|
||||
<string name="smp_servers_add">Add server…</string>
|
||||
<string name="smp_servers_test_server">Test server</string>
|
||||
<string name="smp_servers_test_servers">Test servers</string>
|
||||
<string name="smp_servers_save">Save servers</string>
|
||||
<string name="smp_servers_test_failed">Server test failed!</string>
|
||||
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
|
||||
<string name="smp_servers_scan_qr">Scan server QR code</string>
|
||||
<string name="smp_servers_enter_manually">Enter server manually</string>
|
||||
<string name="smp_servers_preset_server">Preset server</string>
|
||||
<string name="smp_servers_your_server">Your server</string>
|
||||
<string name="smp_servers_your_server_address">Your server address</string>
|
||||
<string name="smp_servers_use_server">Use server</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Use for new connections</string>
|
||||
<string name="smp_servers_add_to_another_device">Add to another device</string>
|
||||
<string name="smp_servers_invalid_address">Invalid server address!</string>
|
||||
<string name="smp_servers_check_address">Check server address and try again.</string>
|
||||
<string name="smp_servers_delete_server">Delete server</string>
|
||||
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
|
||||
<string name="star_on_github">Star on GitHub</string>
|
||||
<string name="contribute">Contribute</string>
|
||||
<string name="rate_the_app">Rate the app</string>
|
||||
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
|
||||
<string name="saved_SMP_servers_will_be_removed">Saved SMP servers will be removed.</string>
|
||||
<string name="your_SMP_servers">Your SMP servers</string>
|
||||
<string name="configure_SMP_servers">Configure SMP servers</string>
|
||||
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
|
||||
<string name="enter_one_SMP_server_per_line">SMP servers (one per line)</string>
|
||||
<string name="how_to">How to</string>
|
||||
<string name="how_to_use_your_servers">How to use your servers</string>
|
||||
<string name="saved_ICE_servers_will_be_removed">Saved WebRTC ICE servers will be removed.</string>
|
||||
<string name="your_ICE_servers">Your ICE servers</string>
|
||||
<string name="configure_ICE_servers">Configure ICE servers</string>
|
||||
@@ -416,7 +471,9 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
|
||||
<string name="edit_image">Edit image</string>
|
||||
<string name="delete_image">Delete image</string>
|
||||
<string name="save_and_notify_contact">Save (and notify contact)</string>
|
||||
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
|
||||
<string name="save_and_notify_group_members">Save (and notify group members)</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">You control your chat!</string>
|
||||
@@ -552,14 +609,16 @@
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Privacy & security</string>
|
||||
<string name="your_privacy">Your privacy</string>
|
||||
<string name="protect_app_screen">Protect app screen</string>
|
||||
<string name="auto_accept_images">Auto-accept images</string>
|
||||
<string name="transfer_images_faster">Transfer images faster (BETA)</string>
|
||||
<string name="transfer_images_faster">Transfer images faster</string>
|
||||
<string name="send_link_previews">Send link previews</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">YOU</string>
|
||||
<string name="settings_section_title_settings">SETTINGS</string>
|
||||
<string name="settings_section_title_help">HELP</string>
|
||||
<string name="settings_section_title_support">SUPPORT SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_develop">DEVELOP</string>
|
||||
<string name="settings_section_title_device">DEVICE</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
@@ -674,6 +733,7 @@
|
||||
<string name="restore_database_alert_desc">Please enter the previous password after restoring database backup. This action can not be undone.</string>
|
||||
<string name="restore_database_alert_confirm">Restore</string>
|
||||
<string name="database_restore_error">Restore database error</string>
|
||||
<string name="restore_passphrase_not_found_desc">Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
|
||||
|
||||
<!-- ChatModel.chatRunning interactions -->
|
||||
<string name="chat_is_stopped_indication">Chat is stopped</string>
|
||||
@@ -791,6 +851,7 @@
|
||||
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
|
||||
<string name="error_creating_link_for_group">Error creating group link</string>
|
||||
<string name="error_deleting_link_for_group">Error deleting group link</string>
|
||||
<string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string>
|
||||
|
||||
<!-- For Console chat info section -->
|
||||
<string name="section_title_for_console">FOR CONSOLE</string>
|
||||
@@ -822,7 +883,7 @@
|
||||
<string name="receiving_via">Receiving via</string>
|
||||
<string name="sending_via">Sending via</string>
|
||||
<string name="network_status">Network status</string>
|
||||
<string name="switch_receiving_address">Switch receiving address (BETA)</string>
|
||||
<string name="switch_receiving_address">Switch receiving address</string>
|
||||
|
||||
<!-- AddGroupView.kt -->
|
||||
<string name="create_secret_group_title">Create secret group</string>
|
||||
@@ -872,4 +933,48 @@
|
||||
<string name="save_color">Save color</string>
|
||||
<string name="reset_color">Reset colors</string>
|
||||
<string name="color_primary">Accent</string>
|
||||
|
||||
<!-- Preferences.kt -->
|
||||
<string name="chat_preferences_you_allow">You allow</string>
|
||||
<string name="chat_preferences_contact_allows">Contact allows</string>
|
||||
<string name="chat_preferences_default">default (%s)</string>
|
||||
<string name="chat_preferences_yes">yes</string>
|
||||
<string name="chat_preferences_no">no</string>
|
||||
<string name="chat_preferences_always">always</string>
|
||||
<string name="chat_preferences_on">on</string>
|
||||
<string name="chat_preferences_off">off</string>
|
||||
<string name="chat_preferences">Chat preferences</string>
|
||||
<string name="contact_preferences">Contact preferences</string>
|
||||
<string name="group_preferences">Group preferences</string>
|
||||
<string name="your_preferences">Your preferences</string>
|
||||
<string name="full_deletion">Full deletion</string>
|
||||
<string name="voice_messages">Voice messages</string>
|
||||
<string name="feature_enabled">enabled</string>
|
||||
<string name="feature_enabled_for_you">enabled for you</string>
|
||||
<string name="feature_enabled_for_contact">enabled for contact</string>
|
||||
<string name="feature_off">off</string>
|
||||
<string name="feature_received_prohibited">received, prohibited</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Allow your contacts to irreversibly delete sent messages.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Allow irreversible message deletion only if your contact allows it to you.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Contacts can mark messages for deletion; you will be able to view them.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
|
||||
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
|
||||
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Both you and your contact can irreversibly delete sent messages.</string>
|
||||
<string name="only_you_can_delete_messages">Only you can irreversibly delete messages (your contact can mark them for deletion).</string>
|
||||
<string name="only_your_contact_can_delete">Only your contact can irreversibly delete messages (you can mark them for deletion).</string>
|
||||
<string name="message_deletion_prohibited">Irreversible message deletion is prohibited in this chat.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Both you and your contact can send voice messages.</string>
|
||||
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
|
||||
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
|
||||
<string name="allow_to_delete_messages">Allow to irreversibly delete sent messages.</string>
|
||||
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
|
||||
<string name="allow_to_send_voice">Allow to send voice messages.</string>
|
||||
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
|
||||
<string name="group_members_can_delete">Group members can irreversibly delete sent messages.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this chat.</string>
|
||||
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
|
||||
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this chat.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -102,9 +102,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
var windowScene: UIWindowScene?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
self.windowScene = windowScene
|
||||
window = windowScene.keyWindow
|
||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
||||
|
||||
@@ -17,6 +17,8 @@ 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 = true
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -29,7 +31,7 @@ struct ContentView: View {
|
||||
} else if let step = chatModel.onboardingStage {
|
||||
if case .onboardingComplete = step,
|
||||
chatModel.currentUser != nil {
|
||||
mainView()
|
||||
mainView().privacySensitive(protectScreen)
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
@@ -46,9 +48,15 @@ struct ContentView: View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView()
|
||||
.onAppear {
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
NtfManager.shared.requestAuthorization(
|
||||
onDeny: {
|
||||
if (!notificationAlertShown) {
|
||||
notificationAlertShown = true
|
||||
alertManager.showAlert(notificationAlert())
|
||||
}
|
||||
},
|
||||
onAuthorized: { notificationAlertShown = false }
|
||||
)
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
if (!prefLANoticeShown && prefShowLANotice) {
|
||||
prefLANoticeShown = true
|
||||
@@ -70,6 +78,7 @@ struct ContentView: View {
|
||||
dismissAllSheets(animated: false) {
|
||||
justAuthenticate()
|
||||
}
|
||||
chatModel.chatId = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
141
apps/ios/Shared/Model/AudioRecPlay.swift
Normal file
141
apps/ios/Shared/Model/AudioRecPlay.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// AudioRecPlay.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 19/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
class AudioRecorder {
|
||||
var onTimer: ((TimeInterval) -> Void)?
|
||||
var onFinishRecording: (() -> Void)?
|
||||
|
||||
var audioRecorder: AVAudioRecorder?
|
||||
var recordingTimer: Timer?
|
||||
|
||||
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishRecording: @escaping (() -> Void)) {
|
||||
self.onTimer = onTimer
|
||||
self.onFinishRecording = onFinishRecording
|
||||
}
|
||||
|
||||
enum StartError {
|
||||
case permission
|
||||
case error(String)
|
||||
}
|
||||
|
||||
func start(fileName: String) async -> StartError? {
|
||||
let av = AVAudioSession.sharedInstance()
|
||||
if !(await checkPermission()) { return .permission }
|
||||
do {
|
||||
try av.setCategory(AVAudioSession.Category.playAndRecord, options: .defaultToSpeaker)
|
||||
try av.setActive(true)
|
||||
let settings: [String : Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 12000,
|
||||
AVEncoderBitRateKey: 12000,
|
||||
AVNumberOfChannelsKey: 1
|
||||
]
|
||||
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
|
||||
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||
|
||||
await MainActor.run {
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
guard let time = self.audioRecorder?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
if time >= MAX_VOICE_MESSAGE_LENGTH {
|
||||
self.stop()
|
||||
self.onFinishRecording?()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} catch let error {
|
||||
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
|
||||
return .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let recorder = audioRecorder {
|
||||
recorder.stop()
|
||||
}
|
||||
audioRecorder = nil
|
||||
if let timer = recordingTimer {
|
||||
timer.invalidate()
|
||||
}
|
||||
recordingTimer = nil
|
||||
}
|
||||
|
||||
private func checkPermission() async -> Bool {
|
||||
let av = AVAudioSession.sharedInstance()
|
||||
switch av.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
DispatchQueue.main.async {
|
||||
av.requestRecordPermission { allowed in
|
||||
cont.resume(returning: allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@unknown default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
var onTimer: ((TimeInterval) -> Void)?
|
||||
var onFinishPlayback: (() -> Void)?
|
||||
|
||||
var audioPlayer: AVAudioPlayer?
|
||||
var playbackTimer: Timer?
|
||||
|
||||
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishPlayback: @escaping (() -> Void)) {
|
||||
self.onTimer = onTimer
|
||||
self.onFinishPlayback = onFinishPlayback
|
||||
}
|
||||
|
||||
func start(fileName: String) {
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
audioPlayer?.play()
|
||||
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
if self.audioPlayer?.isPlaying ?? false {
|
||||
guard let time = self.audioPlayer?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
}
|
||||
|
||||
func play() {
|
||||
audioPlayer?.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let player = audioPlayer {
|
||||
player.stop()
|
||||
}
|
||||
audioPlayer = nil
|
||||
if let timer = playbackTimer {
|
||||
timer.invalidate()
|
||||
}
|
||||
playbackTimer = nil
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
stop()
|
||||
self.onFinishPlayback?()
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ final class ChatModel: ObservableObject {
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: UserContactLink?
|
||||
@Published var userSMPServers: [String]?
|
||||
@Published var userSMPServers: [ServerCfg]?
|
||||
@Published var presetSMPServers: [String]?
|
||||
@Published var chatItemTTL: ChatItemTTL = .none
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var deviceToken: DeviceToken?
|
||||
@@ -51,6 +52,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var showCallView = false
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
|
||||
var callWebView: WKWebView?
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
@@ -386,6 +389,11 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
|
||||
// user member was updated
|
||||
if groupInfo.membership.groupMemberId == member.groupMemberId {
|
||||
updateGroup(groupInfo)
|
||||
return false
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
|
||||
@@ -393,10 +401,6 @@ final class ChatModel: ObservableObject {
|
||||
self.groupMembers[i] = member
|
||||
}
|
||||
return false
|
||||
} else if (groupInfo.membership.groupMemberId == member.groupMemberId) {
|
||||
// Current user was updated (like his role, for example)
|
||||
updateGroup(groupInfo)
|
||||
return true
|
||||
} else {
|
||||
withAnimation { groupMembers.append(member) }
|
||||
return true
|
||||
|
||||
@@ -165,22 +165,23 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
])
|
||||
}
|
||||
|
||||
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
|
||||
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
|
||||
logger.debug("NtfManager.requestAuthorization")
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .denied:
|
||||
if let handler = handler { handler() }
|
||||
return
|
||||
denied?()
|
||||
case .authorized:
|
||||
self.granted = true
|
||||
authorized?()
|
||||
default:
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if let error = error {
|
||||
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
|
||||
} else {
|
||||
self.granted = granted
|
||||
authorized?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,16 +309,27 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||
}
|
||||
|
||||
func getUserSMPServers() throws -> [String] {
|
||||
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||
let r = chatSendCmdSync(.getUserSMPServers)
|
||||
if case let .userSMPServers(smpServers) = r { return smpServers }
|
||||
if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserSMPServers(smpServers: [String]) async throws {
|
||||
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
||||
}
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
|
||||
if case let .smpTestResult(testFailure) = r {
|
||||
if let t = testFailure {
|
||||
return .failure(t)
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
func getChatItemTTL() throws -> ChatItemTTL {
|
||||
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
||||
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||
@@ -379,9 +390,13 @@ func apiConnect(connReq: String) async -> ConnReqType? {
|
||||
case .sentConfirmation: return .invitation
|
||||
case .sentInvitation: return .contact
|
||||
case let .contactAlreadyExists(contact):
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
am.showAlertMsg(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connected to \(contact.displayName) via this link."
|
||||
message: "You are already connected to \(contact.displayName)."
|
||||
)
|
||||
return nil
|
||||
case .chatCmdError(.error(.invalidConnReq)):
|
||||
@@ -472,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
|
||||
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
|
||||
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
||||
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
||||
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
||||
@@ -566,10 +587,15 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
|
||||
)
|
||||
} else if !networkErrorAlert(r) {
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
switch r {
|
||||
case .chatCmdError(.error(.fileAlreadyReceiving)):
|
||||
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
|
||||
default:
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -822,7 +848,7 @@ func startChat() throws {
|
||||
let justStarted = try apiStartChat()
|
||||
if justStarted {
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
m.userSMPServers = try getUserSMPServers()
|
||||
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
@@ -948,20 +974,28 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if case .image = cItem.content.msgContent,
|
||||
let file = cItem.file,
|
||||
file.fileSize <= maxImageSize,
|
||||
file.fileSize <= MAX_IMAGE_SIZE,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
} else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent
|
||||
let file = cItem.file,
|
||||
file.fileSize <= MAX_IMAGE_SIZE,
|
||||
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
|
||||
if !cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
case let .chatItemStatusUpdated(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
var res = false
|
||||
if !cItem.isDeletedContent() {
|
||||
if !cItem.isDeletedContent {
|
||||
res = m.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
if res {
|
||||
@@ -986,10 +1020,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
_ = m.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
case let .receivedGroupInvitation(groupInfo, _, _):
|
||||
m.addChat(Chat(
|
||||
chatInfo: .group(groupInfo: groupInfo),
|
||||
chatItems: []
|
||||
))
|
||||
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
||||
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
|
||||
case let .userAcceptedGroupSent(groupInfo, hostContact):
|
||||
m.updateGroup(groupInfo)
|
||||
@@ -1024,9 +1055,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .sndFileComplete(aChatItem, _):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
let cItem = aChatItem.chatItem
|
||||
let mc = cItem.content.msgContent
|
||||
if aChatItem.chatInfo.chatType == .direct,
|
||||
let mc = cItem.content.msgContent,
|
||||
mc.isFile(),
|
||||
case .file = mc,
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct SimpleXApp: App {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var userAuthorized: Bool?
|
||||
@State private var doAuthenticate = false
|
||||
@State private var enteredBackground: Double? = nil
|
||||
@State private var enteredBackground: TimeInterval? = nil
|
||||
|
||||
init() {
|
||||
hs_init(0, nil)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtocol {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
var contact: Contact
|
||||
@State var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
@@ -99,15 +99,17 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
contactPreferencesButton()
|
||||
}
|
||||
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if developerTools {
|
||||
Button("Change receiving address (BETA)") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
@@ -192,6 +194,20 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func contactPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
contact: $contact,
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||
)
|
||||
.navigationBarTitle("Contact preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Contact preferences", systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
|
||||
func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
|
||||
@@ -54,7 +54,7 @@ struct CICallItemView: View {
|
||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "phone.down")
|
||||
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
|
||||
Text(durationText(duration)).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
Normal file
34
apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// CIChatFeatureView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIChatFeatureView: View {
|
||||
var chatItem: ChatItem
|
||||
var feature: Feature
|
||||
var iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
Image(systemName: feature.icon + ".fill")
|
||||
.foregroundColor(iconColor)
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: .fullDelete, iconColor: enabled.iconColor)
|
||||
}
|
||||
}
|
||||
@@ -20,27 +20,27 @@ struct CIEventView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ eventText()
|
||||
+ chatEventText(chatItem)
|
||||
} else {
|
||||
eventText()
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
func eventText() -> Text {
|
||||
Text(chatItem.content.text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ chatItem.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
Text(ci.content.text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ ci.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
struct CIEventView_Previews: PreviewProvider {
|
||||
|
||||
@@ -50,7 +50,7 @@ struct CIFileView: View {
|
||||
|
||||
func fileSizeValid() -> Bool {
|
||||
if let file = file {
|
||||
return file.fileSize <= maxFileSize
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -66,7 +66,7 @@ struct CIFileView: View {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
|
||||
@@ -79,7 +79,7 @@ struct CIFileView: View {
|
||||
)
|
||||
case .rcvComplete:
|
||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||
if let filePath = getLoadedFilePath(file){
|
||||
if let filePath = getLoadedFilePath(file) {
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
@@ -148,7 +148,7 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
Group{
|
||||
Group {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct CIMetaView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
if !chatItem.isDeletedContent() {
|
||||
if !chatItem.isDeletedContent {
|
||||
if chatItem.meta.itemEdited {
|
||||
statusImage("pencil", metaColor, 9)
|
||||
}
|
||||
|
||||
246
apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
Normal file
246
apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// CIVoiceView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 22.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
VStack (
|
||||
alignment: chatItem.chatDir.sent ? .trailing : .leading,
|
||||
spacing: 6
|
||||
) {
|
||||
HStack {
|
||||
if chatItem.chatDir.sent {
|
||||
playerTime()
|
||||
.frame(width: 50, alignment: .leading)
|
||||
player()
|
||||
} else {
|
||||
player()
|
||||
playerTime()
|
||||
.frame(width: 50, alignment: .leading)
|
||||
}
|
||||
}
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.leading, chatItem.chatDir.sent ? 0 : 12)
|
||||
.padding(.trailing, chatItem.chatDir.sent ? 12 : 0)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func player() -> some View {
|
||||
VoiceMessagePlayer(
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
showBackground: true,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
}
|
||||
|
||||
private func playerTime() -> some View {
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessagePlayerTime: View {
|
||||
var recordingTime: TimeInterval
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Text(voiceMessageTime(recordingTime))
|
||||
case .playing:
|
||||
Text(voiceMessageTime_(playbackTime))
|
||||
case .paused:
|
||||
Text(voiceMessageTime_(playbackTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessagePlayer: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var recordingFile: CIFile?
|
||||
var recordingTime: TimeInterval
|
||||
var showBackground: Bool
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@State private var startingPlayback: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let recordingFile = recordingFile {
|
||||
switch recordingFile.fileStatus {
|
||||
case .sndStored: playbackButton()
|
||||
case .sndTransfer: playbackButton()
|
||||
case .sndComplete: playbackButton()
|
||||
case .sndCancelled: playbackButton()
|
||||
case .rcvInvitation: loadingIcon()
|
||||
case .rcvAccepted: loadingIcon()
|
||||
case .rcvTransfer: loadingIcon()
|
||||
case .rcvComplete: playbackButton()
|
||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
} else {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
audioPlayer?.stop()
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingPlayback {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
} else {
|
||||
startingPlayback = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func playbackButton() -> some View {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingFileName = getLoadedFileName(recordingFile) {
|
||||
startPlayback(recordingFileName)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
} label: {
|
||||
playPauseIcon("pause.fill")
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
||||
ZStack {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||
.clipShape(Circle())
|
||||
if recordingTime > 0 {
|
||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||
.frame(width: 52, height: 52) // this + ProgressCircle lineWidth = background circle diameter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressCircle: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||
.stroke(
|
||||
Color.accentColor,
|
||||
style: StrokeStyle(lineWidth: 4)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear, value: progress)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadingIcon() -> some View {
|
||||
ProgressView()
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
struct CIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let voiceMessageWtFile = ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
|
||||
content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
Group {
|
||||
CIVoiceView(
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
playbackState: .playing,
|
||||
playbackTime: TimeInterval(20)
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample())
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
}
|
||||
76
apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
Normal file
76
apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// FramedCIVoiceView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 22.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct FramedCIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VoiceMessagePlayer(
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
showBackground: false,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 6)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let voiceMessageWithQuote: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
}
|
||||
@@ -67,15 +67,23 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .file(text):
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
case let .voice(text, duration):
|
||||
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .file(text):
|
||||
ciFileView(chatItem, text)
|
||||
case let .link(_, preview):
|
||||
CILinkView(linkPreview: preview)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
case let .unknown(_, text: text):
|
||||
if chatItem.file == nil {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
} else {
|
||||
ciFileView(chatItem, text)
|
||||
}
|
||||
default:
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
@@ -110,27 +118,29 @@ struct FramedItemView: View {
|
||||
|
||||
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
|
||||
let v = ZStack(alignment: .topTrailing) {
|
||||
if case let .image(_, image) = qi.content,
|
||||
let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 68, height: 68)
|
||||
.clipped()
|
||||
} else if case .file = qi.content {
|
||||
switch (qi.content) {
|
||||
case let .image(_, image):
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 68, height: 68)
|
||||
.clipped()
|
||||
} else {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
case .file:
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
|
||||
Image(systemName: "doc.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.padding(.top, 6)
|
||||
.padding(.trailing, 4)
|
||||
} else {
|
||||
ciQuoteIconView("doc.fill")
|
||||
case .voice:
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
|
||||
ciQuoteIconView("mic.fill")
|
||||
default:
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +171,16 @@ struct FramedItemView: View {
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
private func ciQuoteIconView(_ image: String) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.top, 6)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
|
||||
private func membership() -> GroupMember? {
|
||||
switch chatInfo {
|
||||
case let .group(groupInfo: groupInfo): return groupInfo.membership
|
||||
@@ -191,6 +211,14 @@ struct FramedItemView: View {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isRightToLeft(_ s: String) -> Bool {
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
private let linkColor = Color(uiColor: uiLinkColor)
|
||||
|
||||
struct MsgContentView: View {
|
||||
var text: String
|
||||
@@ -70,6 +69,14 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
|
||||
switch privacySimplexLinkModeDefault.get() {
|
||||
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
|
||||
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return trustedUri
|
||||
? linkText(t, t, preview, prefix: "")
|
||||
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
|
||||
}
|
||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||
}
|
||||
@@ -78,16 +85,19 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
||||
}
|
||||
}
|
||||
|
||||
private func linkText(_ s: String, _ link: String,
|
||||
_ preview: Bool, prefix: String) -> Text {
|
||||
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
|
||||
preview
|
||||
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
|
||||
? Text(s).foregroundColor(color).underline(color: color)
|
||||
: Text(AttributedString(s, attributes: AttributeContainer([
|
||||
.link: NSURL(string: prefix + link) as Any,
|
||||
.foregroundColor: uiLinkColor as Any
|
||||
.foregroundColor: uiColor as Any
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
||||
}
|
||||
|
||||
struct MsgContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
|
||||
@@ -31,12 +31,20 @@ struct ChatItemView: View {
|
||||
case .sndGroupEvent: eventItemView()
|
||||
case .rcvConnEvent: eventItemView()
|
||||
case .sndConnEvent: eventItemView()
|
||||
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func contentItemView() -> some View {
|
||||
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
|
||||
EmojiItemView(chatItem: chatItem)
|
||||
} else if chatItem.quotedItem == nil && chatItem.content.text.isEmpty,
|
||||
case let .voice(_, duration) = chatItem.content.msgContent {
|
||||
CIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
|
||||
} else {
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
||||
}
|
||||
@@ -57,6 +65,10 @@ struct ChatItemView: View {
|
||||
private func eventItemView() -> some View {
|
||||
CIEventView(chatItem: chatItem)
|
||||
}
|
||||
|
||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
|
||||
@@ -103,7 +103,7 @@ struct ChatView: View {
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||
connectionStats = nil
|
||||
customUserProfile = nil
|
||||
}) {
|
||||
@@ -121,7 +121,7 @@ struct ChatView: View {
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.sheet(isPresented: $showChatInfoSheet) {
|
||||
.appSheet(isPresented: $showChatInfoSheet) {
|
||||
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ struct ChatView: View {
|
||||
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
|
||||
} else {
|
||||
addMembersButton()
|
||||
.sheet(isPresented: $showAddMembersSheet) {
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
@@ -392,8 +392,11 @@ struct ChatView: View {
|
||||
await MainActor.run { selectedMember = member }
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedMember, onDismiss: { memberConnectionStats = nil }) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: $memberConnectionStats)
|
||||
.appSheet(item: $selectedMember, onDismiss: {
|
||||
selectedMember = nil
|
||||
memberConnectionStats = nil
|
||||
}) { _ in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats)
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
@@ -411,14 +414,14 @@ struct ChatView: View {
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
var menu: [UIAction] = []
|
||||
if ci.isMsgContent() {
|
||||
if let mc = ci.content.msgContent {
|
||||
menu.append(contentsOf: [
|
||||
UIAction(
|
||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
if composeState.editing() {
|
||||
if composeState.editing {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
@@ -448,18 +451,31 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
])
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getLoadedImage(ci.file) {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
)
|
||||
if let filePath = getLoadedFilePath(ci.file) {
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = UIImage(contentsOfFile: filePath) {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
let fileURL = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [fileURL])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if ci.meta.editable {
|
||||
if ci.meta.editable,
|
||||
!mc.isVoice {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||
@@ -481,7 +497,7 @@ struct ChatView: View {
|
||||
deletingItem = ci
|
||||
}
|
||||
)
|
||||
} else if ci.isDeletedContent() {
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
|
||||
@@ -13,6 +13,7 @@ enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
case imagePreviews(imagePreviews: [String])
|
||||
case voicePreview(recordingFileName: String, duration: Int)
|
||||
case filePreview(fileName: String)
|
||||
}
|
||||
|
||||
@@ -22,10 +23,17 @@ enum ComposeContextItem {
|
||||
case editingItem(chatItem: ChatItem)
|
||||
}
|
||||
|
||||
enum VoiceMessageRecordingState {
|
||||
case noRecording
|
||||
case recording
|
||||
case finished
|
||||
}
|
||||
|
||||
struct ComposeState {
|
||||
var message: String
|
||||
var preview: ComposePreview
|
||||
var contextItem: ComposeContextItem
|
||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||
var inProgress = false
|
||||
var disabled = false
|
||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
@@ -33,66 +41,91 @@ struct ComposeState {
|
||||
init(
|
||||
message: String = "",
|
||||
preview: ComposePreview = .noPreview,
|
||||
contextItem: ComposeContextItem = .noContextItem
|
||||
contextItem: ComposeContextItem = .noContextItem,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
||||
) {
|
||||
self.message = message
|
||||
self.preview = preview
|
||||
self.contextItem = contextItem
|
||||
self.voiceMessageRecordingState = voiceMessageRecordingState
|
||||
}
|
||||
|
||||
init(editingItem: ChatItem) {
|
||||
self.message = editingItem.content.text
|
||||
self.preview = chatItemPreview(chatItem: editingItem)
|
||||
self.contextItem = .editingItem(chatItem: editingItem)
|
||||
if let emc = editingItem.content.msgContent,
|
||||
case .voice = emc {
|
||||
self.voiceMessageRecordingState = .finished
|
||||
} else {
|
||||
self.voiceMessageRecordingState = .noRecording
|
||||
}
|
||||
}
|
||||
|
||||
func copy(
|
||||
message: String? = nil,
|
||||
preview: ComposePreview? = nil,
|
||||
contextItem: ComposeContextItem? = nil
|
||||
contextItem: ComposeContextItem? = nil,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
||||
) -> ComposeState {
|
||||
ComposeState(
|
||||
message: message ?? self.message,
|
||||
preview: preview ?? self.preview,
|
||||
contextItem: contextItem ?? self.contextItem
|
||||
contextItem: contextItem ?? self.contextItem,
|
||||
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
|
||||
)
|
||||
}
|
||||
|
||||
func editing() -> Bool {
|
||||
var editing: Bool {
|
||||
switch contextItem {
|
||||
case .editingItem: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func sendEnabled() -> Bool {
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews:
|
||||
return true
|
||||
case .filePreview:
|
||||
return true
|
||||
default:
|
||||
return !message.isEmpty
|
||||
case .imagePreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func linkPreviewAllowed() -> Bool {
|
||||
var linkPreviewAllowed: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews:
|
||||
return false
|
||||
case .filePreview:
|
||||
return false
|
||||
default:
|
||||
return useLinkPreviews
|
||||
case .imagePreviews: return false
|
||||
case .voicePreview: return false
|
||||
case .filePreview: return false
|
||||
default: return useLinkPreviews
|
||||
}
|
||||
}
|
||||
|
||||
func linkPreview() -> LinkPreview? {
|
||||
var linkPreview: LinkPreview? {
|
||||
switch preview {
|
||||
case let .linkPreview(linkPreview):
|
||||
return linkPreview
|
||||
default:
|
||||
return nil
|
||||
case let .linkPreview(linkPreview): return linkPreview
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var voiceMessageRecordingFileName: String? {
|
||||
switch preview {
|
||||
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var noPreview: Bool {
|
||||
switch preview {
|
||||
case .noPreview: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var voicePreview: Bool {
|
||||
switch preview {
|
||||
case .voicePreview: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +137,10 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
chatItemPreview = .noPreview
|
||||
case let .link(_, preview: preview):
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image: image):
|
||||
case let .image(_, image):
|
||||
chatItemPreview = .imagePreviews(imagePreviews: [image])
|
||||
case let .voice(_, duration):
|
||||
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
case .file:
|
||||
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
|
||||
default:
|
||||
@@ -116,7 +151,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
|
||||
struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
let chat: Chat
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
|
||||
@@ -131,12 +166,17 @@ struct ComposeView: View {
|
||||
@State var chosenImages: [UIImage] = []
|
||||
@State private var showFileImporter = false
|
||||
@State var chosenFile: URL? = nil
|
||||
|
||||
@State private var audioRecorder: AudioRecorder?
|
||||
@State private var voiceMessageRecordingTime: TimeInterval?
|
||||
@State private var startingRecording: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
contextItemView()
|
||||
switch (composeState.editing(), composeState.preview) {
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
@@ -146,7 +186,7 @@ struct ComposeView: View {
|
||||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.editing())
|
||||
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
@@ -156,6 +196,15 @@ struct ComposeView: View {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
Task {
|
||||
await startVoiceMessageRecording()
|
||||
}
|
||||
},
|
||||
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
|
||||
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
@@ -163,7 +212,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: composeState.message) { _ in
|
||||
if composeState.linkPreviewAllowed() {
|
||||
if composeState.linkPreviewAllowed {
|
||||
if composeState.message.count > 0 {
|
||||
showLinkPreview(composeState.message)
|
||||
} else {
|
||||
@@ -193,7 +242,7 @@ struct ComposeView: View {
|
||||
CameraImageListPicker(images: $chosenImages)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
.appSheet(isPresented: $showImagePicker) {
|
||||
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
|
||||
showImagePicker = false
|
||||
if itemsSelected {
|
||||
@@ -235,11 +284,11 @@ struct ComposeView: View {
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
if let fileSize = fileSize,
|
||||
fileSize <= maxFileSize {
|
||||
fileSize <= MAX_FILE_SIZE {
|
||||
chosenFile = fileURL
|
||||
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
@@ -250,6 +299,21 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
audioRecorder?.stop()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
cancelVoiceMessageRecording(fileName)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingRecording {
|
||||
if composeState.voiceMessageRecordingState == .recording {
|
||||
finishVoiceMessageRecording()
|
||||
}
|
||||
} else {
|
||||
startingRecording = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func previewView() -> some View {
|
||||
@@ -265,7 +329,15 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenImages = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing())
|
||||
cancelEnabled: !composeState.editing)
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
ComposeVoiceView(
|
||||
recordingFileName: recordingFileName,
|
||||
recordingTime: $voiceMessageRecordingTime,
|
||||
recordingState: $composeState.voiceMessageRecordingState,
|
||||
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
|
||||
cancelEnabled: !composeState.editing
|
||||
)
|
||||
case let .filePreview(fileName: fileName):
|
||||
ComposeFileView(
|
||||
fileName: fileName,
|
||||
@@ -273,7 +345,7 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenFile = nil
|
||||
},
|
||||
cancelEnabled: !composeState.editing())
|
||||
cancelEnabled: !composeState.editing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +426,8 @@ struct ComposeView: View {
|
||||
if !sent {
|
||||
await send(.text(composeState.message), quoted: quoted)
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
case .filePreview:
|
||||
if let fileURL = chosenFile,
|
||||
let savedFile = saveFileFromURL(fileURL) {
|
||||
@@ -386,6 +460,86 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func startVoiceMessageRecording() async {
|
||||
startingRecording = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
let fileName = generateNewFileName("voice", "m4a")
|
||||
audioRecorder = AudioRecorder(
|
||||
onTimer: { voiceMessageRecordingTime = $0 },
|
||||
onFinishRecording: {
|
||||
updateComposeVMRFinished()
|
||||
if let fileSize = fileSize(getAppFilePath(fileName)) {
|
||||
logger.debug("onFinishRecording recording file size = \(fileSize)")
|
||||
}
|
||||
}
|
||||
)
|
||||
if let recStartError = await audioRecorder?.start(fileName: fileName) {
|
||||
switch recStartError {
|
||||
case .permission:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "No permission to record voice message",
|
||||
message: "To record voice message please grant permission to use Microphone."
|
||||
)
|
||||
case let .error(error):
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Unable to record voice message",
|
||||
message: "Error: \(error)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
composeState = composeState.copy(
|
||||
preview: .voicePreview(recordingFileName: fileName, duration: 0),
|
||||
voiceMessageRecordingState: .recording
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishVoiceMessageRecording() {
|
||||
audioRecorder?.stop()
|
||||
audioRecorder = nil
|
||||
updateComposeVMRFinished()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName,
|
||||
let fileSize = fileSize(getAppFilePath(fileName)) {
|
||||
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
|
||||
}
|
||||
}
|
||||
|
||||
private func allowVoiceMessagesToContact() {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
Task {
|
||||
do {
|
||||
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
|
||||
prefs.voice = Preference(allow: .yes)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
|
||||
private func updateComposeVMRFinished() {
|
||||
var preview = composeState.preview
|
||||
if let recordingFileName = composeState.voiceMessageRecordingFileName,
|
||||
let recordingTime = voiceMessageRecordingTime {
|
||||
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
|
||||
}
|
||||
composeState = composeState.copy(
|
||||
preview: preview,
|
||||
voiceMessageRecordingState: .finished
|
||||
)
|
||||
}
|
||||
|
||||
private func cancelVoiceMessageRecording(_ fileName: String) {
|
||||
removeFile(fileName)
|
||||
clearState()
|
||||
}
|
||||
|
||||
private func clearState() {
|
||||
composeState = ComposeState()
|
||||
linkUrl = nil
|
||||
@@ -394,6 +548,9 @@ struct ComposeView: View {
|
||||
cancelledLinks = []
|
||||
chosenImages = []
|
||||
chosenFile = nil
|
||||
audioRecorder?.stop()
|
||||
audioRecorder = nil
|
||||
voiceMessageRecordingTime = nil
|
||||
}
|
||||
|
||||
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
||||
@@ -404,6 +561,8 @@ struct ComposeView: View {
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: composeState.message, image: image)
|
||||
case .voice(_, let duration):
|
||||
return .voice(text: composeState.message, duration: duration)
|
||||
case .file:
|
||||
return .file(composeState.message)
|
||||
case .unknown(let type, _):
|
||||
@@ -415,7 +574,7 @@ struct ComposeView: View {
|
||||
prevLinkUrl = linkUrl
|
||||
linkUrl = parseMessage(s)
|
||||
if let url = linkUrl {
|
||||
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
|
||||
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
||||
pendingLinkUrl = url
|
||||
if prevLinkUrl == url {
|
||||
loadLinkPreview(url)
|
||||
@@ -444,7 +603,7 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private func cancelLinkPreview() {
|
||||
if let uri = composeState.linkPreview()?.uri.absoluteString {
|
||||
if let uri = composeState.linkPreview?.uri.absoluteString {
|
||||
cancelledLinks.insert(uri)
|
||||
}
|
||||
pendingLinkUrl = nil
|
||||
@@ -499,11 +658,13 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
190
apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
Normal file
190
apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// ComposeVoiceView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 21.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum VoiceMessagePlaybackState {
|
||||
case noPlayback
|
||||
case playing
|
||||
case paused
|
||||
}
|
||||
|
||||
func voiceMessageTime(_ time: TimeInterval) -> String {
|
||||
let min = Int(time / 60)
|
||||
let sec = Int(time.truncatingRemainder(dividingBy: 60))
|
||||
return String(format: "%02d:%02d", min, sec)
|
||||
}
|
||||
|
||||
func voiceMessageTime_(_ time: TimeInterval?) -> String {
|
||||
return voiceMessageTime(time ?? TimeInterval(0))
|
||||
}
|
||||
|
||||
struct ComposeVoiceView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var recordingFileName: String
|
||||
@Binding var recordingTime: TimeInterval?
|
||||
@Binding var recordingState: VoiceMessageRecordingState
|
||||
let cancelVoiceMessage: ((String) -> Void)
|
||||
let cancelEnabled: Bool
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State private var playbackTime: TimeInterval?
|
||||
@State private var startingPlayback: Bool = false
|
||||
|
||||
private static let previewHeight: CGFloat = 50
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if recordingState != .finished {
|
||||
recordingMode()
|
||||
} else {
|
||||
playbackMode()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.frame(height: ComposeVoiceView.previewHeight)
|
||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
.onDisappear {
|
||||
audioPlayer?.stop()
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingPlayback {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
} else {
|
||||
startingPlayback = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordingMode() -> some View {
|
||||
ZStack {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
Text(voiceMessageTime_(recordingTime))
|
||||
Spacer()
|
||||
if cancelEnabled {
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
|
||||
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackMode() -> some View {
|
||||
ZStack {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
startPlayback()
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
Text(voiceMessageTime_(recordingTime))
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
} label: {
|
||||
playPauseIcon("pause.fill")
|
||||
}
|
||||
Text(voiceMessageTime_(playbackTime))
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
Text(voiceMessageTime_(playbackTime))
|
||||
}
|
||||
Spacer()
|
||||
if cancelEnabled {
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
|
||||
if let recordingLength = recordingTime {
|
||||
ProgressBar(length: recordingLength, progress: $playbackTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, 12)
|
||||
}
|
||||
|
||||
private func cancelButton() -> some View {
|
||||
Button {
|
||||
audioPlayer?.stop()
|
||||
cancelVoiceMessage(recordingFileName)
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressBar: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
||||
.animation(.linear, value: progress)
|
||||
}
|
||||
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPlayback() {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = recordingTime // animate progress bar to the end
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ComposeVoiceView(
|
||||
recordingFileName: "voice.m4a",
|
||||
recordingTime: Binding.constant(TimeInterval(20)),
|
||||
recordingState: Binding.constant(VoiceMessageRecordingState.recording),
|
||||
cancelVoiceMessage: { _ in },
|
||||
cancelEnabled: true
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ struct ContextItemView: View {
|
||||
let cancelContextItem: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let bgColor = contextItem.chatDir.sent
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
HStack {
|
||||
Image(systemName: contextIcon)
|
||||
.resizable()
|
||||
@@ -41,7 +45,7 @@ struct ContextItemView: View {
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
||||
.background(bgColor)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import SimpleXChat
|
||||
struct SendMessageView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
var sendMessage: () -> Void
|
||||
var showVoiceMessageButton: Bool = true
|
||||
var voiceMessageAllowed: Bool = true
|
||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@@ -23,24 +30,34 @@ struct SendMessageView: View {
|
||||
ZStack {
|
||||
HStack(alignment: .bottom) {
|
||||
ZStack(alignment: .leading) {
|
||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
||||
Text(composeState.message)
|
||||
.lineLimit(10)
|
||||
.font(teFont)
|
||||
.multilineTextAlignment(alignment)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
TextEditor(text: $composeState.message)
|
||||
.focused($keyboardVisible)
|
||||
.font(teFont)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.multilineTextAlignment(alignment)
|
||||
.padding(.horizontal, 5)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
if case .voicePreview = composeState.preview {
|
||||
Text("Voice message…")
|
||||
.font(teFont.italic())
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
||||
Text(composeState.message)
|
||||
.lineLimit(10)
|
||||
.font(teFont)
|
||||
.multilineTextAlignment(alignment)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
TextEditor(text: $composeState.message)
|
||||
.focused($keyboardVisible)
|
||||
.font(teFont)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.multilineTextAlignment(alignment)
|
||||
.padding(.horizontal, 5)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
}
|
||||
|
||||
if (composeState.inProgress) {
|
||||
@@ -49,14 +66,27 @@ struct SendMessageView: View {
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
.padding([.bottom, .trailing], 3)
|
||||
} else {
|
||||
Button(action: { sendMessage() }) {
|
||||
Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if showVoiceMessageButton,
|
||||
composeState.message.isEmpty,
|
||||
!composeState.editing,
|
||||
(composeState.noPreview && vmrs == .noRecording)
|
||||
|| (vmrs == .recording && holdingVMR) {
|
||||
if voiceMessageAllowed {
|
||||
RecordVoiceMessageButton(
|
||||
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
holdingVMR: $holdingVMR,
|
||||
disabled: composeState.disabled
|
||||
)
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
}
|
||||
} else if vmrs == .recording && !holdingVMR {
|
||||
finishVoiceMessageRecordingButton()
|
||||
} else {
|
||||
sendMessageButton()
|
||||
}
|
||||
.disabled(!composeState.sendEnabled() || composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +97,109 @@ struct SendMessageView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
private func sendMessageButton() -> some View {
|
||||
Button(action: { sendMessage() }) {
|
||||
Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.disabled ||
|
||||
(!voiceMessageAllowed && composeState.voicePreview)
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private struct RecordVoiceMessageButton: View {
|
||||
var startVoiceMessageRecording: (() -> Void)?
|
||||
var finishVoiceMessageRecording: (() -> Void)?
|
||||
@Binding var holdingVMR: Bool
|
||||
var disabled: Bool
|
||||
@State private var pressed: TimeInterval? = nil
|
||||
|
||||
var body: some View {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "mic.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
._onButtonGesture { down in
|
||||
if down {
|
||||
holdingVMR = true
|
||||
pressed = ProcessInfo.processInfo.systemUptime
|
||||
startVoiceMessageRecording?()
|
||||
} else {
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
if let pressed = pressed,
|
||||
now - pressed >= 1 {
|
||||
finishVoiceMessageRecording?()
|
||||
}
|
||||
holdingVMR = false
|
||||
pressed = nil
|
||||
}
|
||||
} perform: {}
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceMessageNotAllowedButton() -> some View {
|
||||
Button(action: {
|
||||
switch showEnableVoiceMessagesAlert {
|
||||
case .userEnable:
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Allow voice messages?"),
|
||||
message: Text("You need to allow your contact to send voice messages to be able to send them."),
|
||||
primaryButton: .default(Text("Allow")) {
|
||||
allowVoiceMessagesToContact?()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
case .askContact:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Voice messages prohibited!",
|
||||
message: "Please ask your contact to enable sending voice messages."
|
||||
)
|
||||
case .groupOwnerCan:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Voice messages prohibited!",
|
||||
message: "Only group owners can enable voice messages."
|
||||
)
|
||||
case .other:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Voice messages prohibited!",
|
||||
message: "Please check yours and your contact preferences."
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "mic")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func finishVoiceMessageRecordingButton() -> some View {
|
||||
Button(action: { finishVoiceMessageRecording?() }) {
|
||||
Image(systemName: "stop.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
DispatchQueue.main.async {
|
||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||
teFont = isShortEmoji(composeState.message)
|
||||
? composeState.message.count < 4
|
||||
? largeEmojiFont
|
||||
: mediumEmojiFont
|
||||
: .body
|
||||
? largeEmojiFont
|
||||
: mediumEmojiFont
|
||||
: .body
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
86
apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
Normal file
86
apps/ios/Shared/Views/Chat/ContactPreferencesView.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// ContactPreferencesView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 13/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ContactPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var contact: Contact
|
||||
@State var featuresAllowed: ContactFeaturesAllowed
|
||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
|
||||
var body: some View {
|
||||
let user: User = chatModel.currentUser!
|
||||
|
||||
VStack {
|
||||
List {
|
||||
// featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||
|
||||
Section {
|
||||
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||
Button("Save (and notify contact)") { savePreferences() }
|
||||
}
|
||||
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
|
||||
let enabled = FeatureEnabled.enabled(
|
||||
user: Preference(allow: allowFeature.wrappedValue.allowed),
|
||||
contact: pref.contactPreference
|
||||
)
|
||||
return Section {
|
||||
Picker("You allow", selection: allowFeature) {
|
||||
ForEach(ContactFeatureAllowed.values(userDefault)) { allow in
|
||||
Text(allow.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||
} header: {
|
||||
HStack {
|
||||
Image(systemName: "\(feature.icon).fill")
|
||||
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||
Text(feature.text)
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
contact = toContact
|
||||
chatModel.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactPreferencesView(
|
||||
contact: Binding.constant(Contact.sampleData),
|
||||
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
@State private var showGroupProfile: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
groupPreferencesButton()
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
Text("Only group owners can change group preferences.")
|
||||
}
|
||||
|
||||
Section("\(members.count + 1) members") {
|
||||
if groupInfo.canAddMembers {
|
||||
groupLinkButton()
|
||||
@@ -68,20 +78,17 @@ struct GroupChatInfoView: View {
|
||||
} label: { memberView(member) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddMembersSheet) {
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
.sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: $connectionStats)
|
||||
}
|
||||
.sheet(isPresented: $showGroupProfile) {
|
||||
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
|
||||
.appSheet(item: $selectedMember, onDismiss: {
|
||||
selectedMember = nil
|
||||
connectionStats = nil
|
||||
}) { _ in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
||||
}
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
@@ -186,16 +193,35 @@ struct GroupChatInfoView: View {
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Group link", systemImage: "link")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupPreferencesView(
|
||||
groupInfo: $groupInfo,
|
||||
preferences: groupInfo.fullGroupPreferences,
|
||||
currentPreferences: groupInfo.fullGroupPreferences
|
||||
)
|
||||
.navigationBarTitle("Group preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Group preferences", systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
|
||||
func editGroupButton() -> some View {
|
||||
Button {
|
||||
showGroupProfile = true
|
||||
NavigationLink {
|
||||
GroupProfileView(
|
||||
groupInfo: $groupInfo,
|
||||
groupProfile: groupInfo.groupProfile
|
||||
)
|
||||
.navigationBarTitle("Group profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Edit group profile", systemImage: "pencil")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct GroupLinkView: View {
|
||||
|
||||
private enum GroupLinkAlert: Identifiable {
|
||||
case deleteLink
|
||||
case error(title: LocalizedStringKey, error: String = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack (alignment: .leading) {
|
||||
Text("Group link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.padding(.bottom)
|
||||
if let groupLink = groupLink {
|
||||
@@ -59,7 +55,7 @@ struct GroupLinkView: View {
|
||||
} catch let error {
|
||||
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating group link")
|
||||
alert = .error(title: a.title, error: "\(a.message)")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
} label: { Label("Create link", systemImage: "link.badge.plus") }
|
||||
@@ -88,7 +84,7 @@ struct GroupLinkView: View {
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text("\(error)"))
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,22 @@ struct GroupMemberInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var groupInfo: GroupInfo
|
||||
@State var member: GroupMember
|
||||
@Binding var member: GroupMember?
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case removeMemberAlert
|
||||
case changeMemberRoleAlert(role: GroupMemberRole)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .removeMemberAlert: return "removeMemberAlert"
|
||||
case let .changeMemberRoleAlert(role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
@@ -37,81 +37,75 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
groupMemberInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
if let member = member {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if let contactId = member.memberContactId {
|
||||
Section {
|
||||
openDirectChatButton(contactId)
|
||||
if let contactId = member.memberContactId {
|
||||
Section {
|
||||
openDirectChatButton(contactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
HStack {
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Role")
|
||||
Spacer()
|
||||
Text(member.memberRole.text)
|
||||
.foregroundStyle(.secondary)
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
}
|
||||
.onAppear { newRole = member.memberRole }
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(role: newRole)
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
if developerTools {
|
||||
Button("Change receiving address (BETA)") {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
}
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
.navigationBarHidden(true)
|
||||
.onAppear { newRole = member.memberRole }
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case .removeMemberAlert: return removeMemberAlert()
|
||||
case .changeMemberRoleAlert: return changeMemberRoleAlert()
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
@@ -135,25 +129,27 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
if let chat = chat {
|
||||
dismissAllSheets(animated: true)
|
||||
chatModel.chatId = chat.id
|
||||
DispatchQueue.main.async {
|
||||
chatModel.chatId = chat.id
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoHeader() -> some View {
|
||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||
VStack {
|
||||
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
|
||||
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(member.displayName)
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
if member.fullName != "" && member.fullName != member.displayName {
|
||||
Text(member.fullName)
|
||||
if mem.fullName != "" && mem.fullName != mem.displayName {
|
||||
Text(mem.fullName)
|
||||
.font(.title2)
|
||||
.lineLimit(2)
|
||||
}
|
||||
@@ -161,25 +157,25 @@ struct GroupMemberInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
func removeMemberButton() -> some View {
|
||||
func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .removeMemberAlert
|
||||
alert = .removeMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberAlert() -> Alert {
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let member = try await apiRemoveMember(groupInfo.groupId, member.groupMemberId)
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, member)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
@@ -193,20 +189,21 @@ struct GroupMemberInfoView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func changeMemberRoleAlert() -> Alert {
|
||||
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Change member role?"),
|
||||
message: member.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
|
||||
message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
|
||||
primaryButton: .default(Text("Change")) {
|
||||
Task {
|
||||
do {
|
||||
let mem = try await apiMemberRole(groupInfo.groupId, member.groupMemberId, newRole)
|
||||
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
|
||||
await MainActor.run {
|
||||
member = mem
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
member = updatedMember
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
newRole = member.memberRole
|
||||
newRole = mem.memberRole
|
||||
logger.error("apiMemberRole error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing role")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
@@ -214,7 +211,7 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel {
|
||||
newRole = member.memberRole
|
||||
newRole = mem.memberRole
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -222,7 +219,9 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
if let member = member {
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -238,7 +237,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
member: GroupMember.sampleData,
|
||||
member: Binding.constant(GroupMember.sampleData),
|
||||
connectionStats: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
|
||||
84
apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
Normal file
84
apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// GroupPreferencesView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 16.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct GroupPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var preferences: FullGroupPreferences
|
||||
@State var currentPreferences: FullGroupPreferences
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
// featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
Button("Save (and notify group members)") { savePreferences() }
|
||||
}
|
||||
.disabled(currentPreferences == preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
|
||||
Section {
|
||||
if (groupInfo.canEdit) {
|
||||
settingsRow(feature.icon) {
|
||||
Picker(feature.text, selection: enableFeature) {
|
||||
ForEach(GroupFeatureEnabled.values) { enable in
|
||||
Text(enable.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
else {
|
||||
settingsRow(feature.icon) {
|
||||
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
var gp = groupInfo.groupProfile
|
||||
gp.groupPreferences = toGroupPreferences(preferences)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
chatModel.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
} catch {
|
||||
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupPreferencesView(
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
preferences: FullGroupPreferences.sampleData,
|
||||
currentPreferences: FullGroupPreferences.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import SimpleXChat
|
||||
struct GroupProfileView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var groupId: Int64
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var groupProfile: GroupProfile
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@@ -82,7 +82,7 @@ struct GroupProfileView: View {
|
||||
CameraImagePicker(image: $chosenImage)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
.appSheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
chatModel.updateGroup(gInfo)
|
||||
dismiss()
|
||||
}
|
||||
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
|
||||
|
||||
struct GroupProfileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
|
||||
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ struct ContactConnectionView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.sheet(isPresented: $showContactConnectionInfo) {
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ enum DatabaseEncryptionAlert: Identifiable {
|
||||
case changeDatabaseKey
|
||||
case databaseEncrypted
|
||||
case currentPassphraseError
|
||||
case error(title: LocalizedStringKey, error: String = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -155,7 +155,7 @@ struct DatabaseEncryptionView: View {
|
||||
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
||||
await operationEnded(.currentPassphraseError)
|
||||
} else {
|
||||
await operationEnded(.error(title: "Error encrypting database", error: responseError(error)))
|
||||
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ struct DatabaseEncryptionView: View {
|
||||
message: Text("Please enter correct current passphrase.")
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text("\(error)"))
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user